diff options
Diffstat (limited to 'packages/meshbay-android/app/src')
20 files changed, 1616 insertions, 7 deletions
diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml index 2eae3ea..37c18c7 100644 --- a/packages/meshbay-android/app/src/main/AndroidManifest.xml +++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml @@ -8,6 +8,19 @@ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> + <!-- Notifications from the hub, fetched or pushed; asked for when the + person turns them on, never at start. --> + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> + <!-- The periodic fetch survives a reboot (JobInfo.setPersisted). --> + <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> + <!-- Photo backup: the photos, asked for when the person turns it on. Not + ACCESS_MEDIA_LOCATION — without it the platform redacts a photo's + location from the bytes this application reads, so a camera roll sent + to a group does not say where its owner lives. --> + <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> + <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" /> + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <!-- No backup of any kind: the keys are wrapped by a Keystore key that a restore cannot bring with it, so a backed-up store is one that silently @@ -36,6 +49,23 @@ android:name=".cast.CastService" android:exported="false" android:foregroundServiceType="mediaPlayback" /> + <service + android:name=".photos.BackupService" + android:exported="false" + android:foregroundServiceType="dataSync" /> + <!-- Not exported: the connector's own receiver takes the distributor's + broadcasts and hands them here inside the application. --> + <service + android:name=".notify.PushReceiver" + android:exported="false"> + <intent-filter> + <action android:name="org.unifiedpush.android.connector.PUSH_EVENT" /> + </intent-filter> + </service> + <service + android:name=".notify.PollJob" + android:exported="false" + android:permission="android.permission.BIND_JOB_SERVICE" /> <meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" android:value="org.meshbay.client.cast.CastOptionsProvider" /> diff --git a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js index b71b5e8..edfdb11 100644 --- a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js +++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js @@ -186,6 +186,33 @@ keepAlive: (on) => call('playback:keep-alive', on === true), }, + // Notifications while closed: fetched, or pushed through a UnifiedPush + // distributor when the phone has one. Phone-only: the + // desktop preload has no counterpart, so `platform.push` is absent there. + push: { + status: () => call('push:status'), + enable: () => call('push:enable'), + disable: () => call('push:disable'), + remember: (subscription, account, secret, since) => + call('push:remember', subscription, account, secret, since), + }, + + // Photo backup (§9.12): the phone lists its photos, keeps what was sent, + // and hands each photo's bytes over at /photosync/<token> on this origin. + // The page decides when and does the sending. Phone-only, like `push`. + photoSync: { + status: () => call('photosync:status'), + permit: () => call('photosync:permit'), + albums: () => call('photosync:albums'), + configure: (settings) => call('photosync:configure', settings || null), + estimate: (settings) => call('photosync:estimate', settings), + plan: () => call('photosync:plan'), + sent: (token, dir, name) => call('photosync:sent', token, dir, name), + completed: () => call('photosync:completed'), + failed: (code, text) => call('photosync:failed', code, text), + keepAlive: (on, text) => call('photosync:keep-alive', on === true, text || ''), + }, + // Where downloads go, chosen once. A display name comes back, never a URI. folder: { choose: () => call('folder:choose'), diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt index 0fa63d6..16ea1cd 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt @@ -32,6 +32,11 @@ import org.meshbay.client.cast.CastChannels import org.meshbay.client.cast.CastService import org.meshbay.client.hub.HubClient import org.meshbay.client.keys.SecretStore +import org.meshbay.client.notify.Notifier +import org.meshbay.client.notify.PushChannels +import org.meshbay.client.notify.PushState +import org.meshbay.client.photos.BackupService +import org.meshbay.client.photos.PhotoChannels import org.meshbay.client.save.SaveSinks import org.meshbay.client.shell.Pickers import org.meshbay.client.shell.ShellWebView @@ -53,6 +58,8 @@ class MainActivity : Activity() { private lateinit var cast: CastChannels private lateinit var hub: HubClient private lateinit var channels: Channels + private lateinit var photos: PhotoChannels + private var network: android.net.ConnectivityManager.NetworkCallback? = null private val pickers = Pickers(this) private val text = NativeText { code -> try { assets.open("ui/locales/$code.js").bufferedReader().use { it.readText() } } catch (e: java.io.IOException) { null } @@ -60,8 +67,10 @@ class MainActivity : Activity() { private var shim: ScriptHandler? = null private var casting = false private var playing = false + private var syncing = false private var fullscreen: View? = null private var fullscreenCallback: WebChromeClient.CustomViewCallback? = null + private var pendingLink: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -87,15 +96,35 @@ class MainActivity : Activity() { Thread { saves.cleanUpAfterAKilledProcess() }.start() cast = CastChannels(this, onCasting = { on -> runOnUiThread { casting = on; keepAlive() } }, tell = { m -> runOnUiThread { android.widget.Toast.makeText(this, m, android.widget.Toast.LENGTH_LONG).show() } }) + photos = PhotoChannels(this, getSharedPreferences(PhotoChannels.PREFS, Context.MODE_PRIVATE), + java.io.File(filesDir, "photosync"), + onKeepAlive = { on, line -> runOnUiThread { backup(on, line) } }) channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } }, hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves, - cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } }) + cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } }, + push = PushChannels(this, PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE)), + channelNames = { mapOf( + Notifier.CHANNEL_CHAT to text.get("push.channel_chat", channels.locale), + Notifier.CHANNEL_OTHER to text.get("push.channel_other", channels.locale)) }), + photos = photos) WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels)) cast.control.warmUp() installShim() + // Opened from a notification: to what it was about, once the page is up. + pendingLink = Notifier.linkOf(intent) web.loadUrl(UiAssets.START) + watchNetwork() } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + Notifier.linkOf(intent)?.let { if (::web.isInitialized) goTo(it) } + } + + /** A route inside the page (`#/…`), checked by Notifier — never a URL to load. */ + private fun goTo(link: String) = web.evaluateJavascript("location.hash = ${JSONObject.quote(link)};", null) + private fun configure(web: WebView) { WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) web.settings.apply { @@ -116,8 +145,15 @@ class MainActivity : Activity() { .addPathHandler(UiAssets.PREFIX, UiAssets(this)) .build() web.webViewClient = object : WebViewClientCompat() { + override fun onPageFinished(view: WebView, url: String) { + pendingLink?.let { pendingLink = null; goTo(it) } + } + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? { val url = request.url + if (url.host == UiAssets.HOST && url.path?.startsWith(PhotoChannels.PATH) == true) { + return photos.serve(url.path ?: "") ?: refused() + } if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused() // reCAPTCHA (sign-up) and nothing else goes to the network from // the page; the policy says the same, this is the second wall. @@ -235,11 +271,12 @@ class MainActivity : Activity() { /** * A cast, or music playing here, keeps the process, the Wi-Fi and the page * alive with the screen off (spike S-2a, scenario F): the foreground service - * holds the first two, the WebView reported visible holds the third. + * holds the first two, the WebView reported visible holds the third. A photo + * backup holds the page here too; its service is its own (`backup`). */ private fun keepAlive() { val on = casting || playing - web.keepVisible = on + web.keepVisible = on || syncing val service = Intent(this, CastService::class.java) if (!on) { stopService(service); return } service.putExtra(CastService.EXTRA_TEXT, @@ -249,6 +286,53 @@ class MainActivity : Activity() { try { startForegroundService(service) } catch (e: IllegalStateException) { Log.w(Bridge.TAG, "keep-alive refused: $e") } } + /** + * A photo backup running: its own foreground service, and the page kept + * visible like a cast. Started once; afterwards only its line changes, + * which needs no start — refused from the background on Android 12+. + */ + private fun backup(on: Boolean, line: String) { + val service = Intent(this, BackupService::class.java) + if (on && syncing) { BackupService.update(this, line); return } + if (on == syncing) return + syncing = on + if (on) { + try { startForegroundService(service.putExtra(BackupService.EXTRA_TEXT, line)) } + catch (e: IllegalStateException) { Log.w(Bridge.TAG, "backup keep-alive refused: $e") } + } else stopService(service) + keepAlive() + } + + /** + * Tells the page when the network becomes unmetered or stops being: a + * backup waiting for Wi-Fi starts, one running on it stops. An event on the + * window, carrying the one boolean and nothing about the network. + */ + private fun watchNetwork() { + val cm = getSystemService(android.net.ConnectivityManager::class.java) + var last: Boolean? = null + val callback = object : android.net.ConnectivityManager.NetworkCallback() { + override fun onCapabilitiesChanged(n: android.net.Network, caps: android.net.NetworkCapabilities) = tell() + override fun onLost(n: android.net.Network) = tell() + private fun tell() { + val now = photos.unmetered() + if (now == last) return + last = now + runOnUiThread { + if (::web.isInitialized) web.evaluateJavascript( + "window.dispatchEvent(new CustomEvent('meshbay-network', { detail: { unmetered: $now } }));", null) + } + } + } + try { cm.registerDefaultNetworkCallback(callback); network = callback } + catch (e: Exception) { Log.w(Bridge.TAG, "no network callback: $e") } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { + if (::photos.isInitialized && photos.deliverPermission(requestCode)) return + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + private fun hasAsset(path: String) = try { assets.open(path).close(); true } catch (e: java.io.IOException) { false } private fun refused() = WebResourceResponse("text/plain", "utf-8", 403, "Forbidden", emptyMap(), "".byteInputStream()) @@ -312,6 +396,8 @@ class MainActivity : Activity() { override fun onDestroy() { if (::cast.isInitialized && cast.relay.active) cast.relay.stop() if (casting || playing) { casting = false; playing = false; keepAlive() } + if (syncing) backup(false, "") + network?.let { try { getSystemService(android.net.ConnectivityManager::class.java).unregisterNetworkCallback(it) } catch (e: Exception) {} } if (::web.isInitialized) { root.removeView(web); web.destroy() } super.onDestroy() } diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt index f7094a0..1775d57 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt @@ -3,6 +3,8 @@ package org.meshbay.client.bridge import org.json.JSONArray import org.meshbay.client.cast.CastChannels import org.meshbay.client.hub.HubClient +import org.meshbay.client.notify.PushChannels +import org.meshbay.client.photos.PhotoChannels import org.meshbay.client.save.BinaryFrame import org.meshbay.client.save.SaveSinks import java.net.Inet4Address @@ -26,6 +28,8 @@ class Channels( private val saves: SaveSinks? = null, private val cast: CastChannels? = null, private val onPlayback: (Boolean) -> Unit = {}, + private val push: PushChannels? = null, + private val photos: PhotoChannels? = null, ) { @Volatile var locale = "en" private set @@ -53,6 +57,8 @@ class Channels( else -> when { keys != null && keys.handles(channel) -> keys.call(channel, args) cast != null && cast.handles(channel) -> cast.call(channel, args) + push != null && push.handles(channel) -> push.call(channel, args) + photos != null && photos.handles(channel) -> photos.call(channel, args) else -> throw Refused("Refused: no such channel") } } diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt index cf20a0b..a48c4cf 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt @@ -172,7 +172,7 @@ class Transcripts(private val now: () -> Double = { System.currentTimeMillis() / "tmdb_config", "tmdb_enabled", "tmdb_override", "tmdb_rematch", "musicbrainz_enabled", "root_remove", "root_eject", "root_plug", "app_directories", "chat_directory", "chat_link_preview", "search_listed", - "chat_epoch", + "chat_epoch", "chat_purge", ) const val PREFIX_JOIN = "meshbay:join:v1" const val PREFIX_DEVICE_REQUEST = "meshbay:device_req:v1" diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt new file mode 100644 index 0000000..7e6fce6 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt @@ -0,0 +1,91 @@ +package org.meshbay.client.notify + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import org.json.JSONObject +import org.meshbay.client.MainActivity +import org.meshbay.client.R + +/** + * A notification from the hub, pushed or fetched, drawn by the system. + * + * The text is the hub's own line ("… posted in …") — the hub never holds a + * message, so neither does a push. A conversation is one entry per group, + * replaced as it goes on, as it is one row on the hub. + */ +object Notifier { + const val CHANNEL_CHAT = "chat" + const val CHANNEL_OTHER = "account" + const val EXTRA_LINK = "org.meshbay.client.LINK" + private val LINK = Regex("^#/[A-Za-z0-9/_-]{0,200}$") + + data class Shown(val channel: String, val tag: String, val title: String, val link: String?, + val id: Long, val createdAt: String) + + /** + * What to draw for a payload, or null for one that is not ours to draw. + * It was decrypted with this phone's key or fetched from the signed-in hub + * with this phone's secret; it is still checked like any input. + */ + fun parse(content: ByteArray): Shown? { + val o = try { JSONObject(String(content, Charsets.UTF_8)) } catch (e: Exception) { return null } + if (o.optInt("v", 0) != 1) return null + val kind = o.optString("kind", "").take(32) + val title = o.optString("title", "").take(256).ifBlank { return null } + val group = if (o.isNull("group_id")) "" else o.optString("group_id", "").take(64) + val link = (if (o.isNull("link")) null else o.optString("link", null))?.takeIf { LINK.matches(it) } + val chat = kind == "chat_message" && group.isNotEmpty() + val id = o.optLong("id", 0) + val createdAt = o.optString("created_at", "").take(40) + val tag = if (chat) "chat:$group" else "n:$id" + return Shown(if (chat) CHANNEL_CHAT else CHANNEL_OTHER, tag, title, link, id, createdAt) + } + + /** Draw it unless it was already drawn — pushed, then fetched, is one line. */ + fun deliver(context: Context, state: PushState, shown: Shown) { + if (state.firstSight(shown.id, shown.createdAt)) show(context, shown) + } + + /** + * The two channels, named in the person's language. Called with names when + * the page turns push on; from the receiver with none, only to make sure a + * channel exists, so a localized name is never overwritten by the fallback. + */ + fun ensureChannels(context: Context, names: Map<String, String>? = null) { + val nm = context.getSystemService(NotificationManager::class.java) + for ((id, fallback, importance) in listOf( + Triple(CHANNEL_CHAT, "Messages", NotificationManager.IMPORTANCE_DEFAULT), + Triple(CHANNEL_OTHER, "Invitations and account", NotificationManager.IMPORTANCE_DEFAULT), + )) { + if (names == null && nm.getNotificationChannel(id) != null) continue + nm.createNotificationChannel(NotificationChannel(id, names?.get(id) ?: fallback, importance)) + } + } + + fun show(context: Context, shown: Shown) { + ensureChannels(context) + val open = Intent(context, MainActivity::class.java) + .setAction(Intent.ACTION_VIEW) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + shown.link?.let { open.putExtra(EXTRA_LINK, it) } + val pending = PendingIntent.getActivity(context, shown.tag.hashCode(), open, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + val n = Notification.Builder(context, shown.channel) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle("MeshBay") + .setContentText(shown.title) + .setStyle(Notification.BigTextStyle().bigText(shown.title)) + .setContentIntent(pending) + .setAutoCancel(true) + .setCategory(if (shown.channel == CHANNEL_CHAT) Notification.CATEGORY_MESSAGE else Notification.CATEGORY_SOCIAL) + .build() + context.getSystemService(NotificationManager::class.java).notify(shown.tag, 1, n) + } + + /** A link from a notification the shell itself drew, checked again on the way in. */ + fun linkOf(intent: Intent?): String? = intent?.getStringExtra(EXTRA_LINK)?.takeIf { LINK.matches(it) } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt new file mode 100644 index 0000000..0998d00 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt @@ -0,0 +1,65 @@ +package org.meshbay.client.notify + +import android.app.job.JobParameters +import android.app.job.JobService +import android.content.Context +import android.util.Log +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import org.meshbay.client.bridge.Bridge +import org.meshbay.client.hub.HubClient +import java.util.concurrent.TimeUnit + +/** + * One fetch of what is new (`POST /v1/push/poll`), page running or not. + * + * Authenticated by the row's poll secret, never a session: what this keeps for + * running in the background reads notification lines and nothing else. It goes + * to the hub the application is signed in to, and only there. + */ +class PollJob : JobService() { + @Volatile private var worker: Thread? = null + + override fun onStartJob(params: JobParameters): Boolean { + worker = Thread { + try { fetch() } catch (e: Exception) { Log.w(Bridge.TAG, "poll failed: ${e.javaClass.simpleName}") } + jobFinished(params, false) + }.apply { start() } + return true + } + + override fun onStopJob(params: JobParameters): Boolean { + worker?.interrupt() + return false + } + + private fun fetch() { + val state = PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE)) + val target = state.pollTarget() ?: return + val base = HubClient(getSharedPreferences("shell", Context.MODE_PRIVATE)).base + val url = base.toHttpUrlOrNull()?.newBuilder()?.encodedPath("/v1/push/poll")?.build() ?: return + val body = JSONObject().put("id", target.id).put("secret", target.secret).put("since", target.since) + .toString().toRequestBody("application/json".toMediaType()) + val http = OkHttpClient.Builder().callTimeout(30, TimeUnit.SECONDS).followRedirects(false).build() + http.newCall(Request.Builder().url(url).post(body).build()).execute().use { r -> + when { + // The row is gone — signed out elsewhere, or deleted: the page + // registers again when it next runs, if push is still on. + r.code == 404 -> state.forgetSubscription() + r.isSuccessful -> { + val list = JSONObject(r.body.string()).optJSONArray("notifications") ?: return + for (i in 0 until list.length()) { + val raw = list.optJSONObject(i)?.toString()?.toByteArray() ?: continue + val shown = Notifier.parse(raw) ?: continue + Notifier.deliver(this, state, shown) + state.advance(shown.createdAt) + } + } + } + } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt new file mode 100644 index 0000000..8f58512 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt @@ -0,0 +1,29 @@ +package org.meshbay.client.notify + +import android.app.job.JobInfo +import android.app.job.JobScheduler +import android.content.ComponentName +import android.content.Context + +/** + * The periodic fetch, through the system's own job scheduler: no library and + * nothing the platform does not already run. Android decides the exact moment + * (fifteen minutes is the shortest period it allows, and Doze stretches it); + * a phone that also gets pushes keeps a slow fetch as a net under them. + */ +object Poller { + private const val JOB_ID = 4208 + private const val FETCHING_MS = 15L * 60 * 1000 + private const val UNDER_PUSH_MS = 4L * 3600 * 1000 + + fun schedule(context: Context, pushed: Boolean) { + val job = JobInfo.Builder(JOB_ID, ComponentName(context, PollJob::class.java)) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setPeriodic(if (pushed) UNDER_PUSH_MS else FETCHING_MS) + .setPersisted(true) + .build() + context.getSystemService(JobScheduler::class.java).schedule(job) + } + + fun cancel(context: Context) = context.getSystemService(JobScheduler::class.java).cancel(JOB_ID) +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt new file mode 100644 index 0000000..e8619a5 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt @@ -0,0 +1,76 @@ +package org.meshbay.client.notify + +import android.app.Activity +import android.app.NotificationManager +import android.os.Build +import org.json.JSONArray +import org.json.JSONObject +import org.unifiedpush.android.connector.UnifiedPush + +/** + * Notifications on this phone (§11.3), with nothing to install. + * + * Turned on, the phone fetches what is new every quarter of an hour (`PollJob`). + * If a UnifiedPush distributor is already on the phone — ntfy, or an application + * that carries one — it is used as well, and notifications arrive at once; if it + * refuses or goes away, the phone simply goes back to fetching. The page gives + * the hub whatever this side ends up with. Phone-only: the desktop has no + * counterpart, and its preload has no such channels. + * + * Whether a notification is wanted at all — every one turned off, or one group + * muted — is decided on the hub, which then creates nothing, so nothing is + * pushed or fetched. Nothing here second-guesses it. + */ +class PushChannels( + private val activity: Activity, + private val state: PushState, + private val channelNames: () -> Map<String, String>, +) { + fun handles(channel: String) = channel.startsWith("push:") + + fun call(channel: String, args: JSONArray): Any? = when (channel) { + "push:status" -> status() + "push:enable" -> enable() + "push:disable" -> { disable(); status() } + "push:remember" -> { + state.remember(args.optString(0, ""), args.optString(1, ""), args.optString(2, ""), args.optString(3, "")) + Poller.schedule(activity, pushed = state.endpoint != null) + status() + } + else -> throw org.meshbay.client.bridge.Refused("Refused: no such channel") + } + + private fun distributors() = UnifiedPush.getDistributors(activity).any { it != activity.packageName } + + private fun status(): JSONObject = state.status() + .put("distributors", distributors()) + .put("permitted", activity.getSystemService(NotificationManager::class.java).areNotificationsEnabled()) + + private fun enable(): JSONObject { + state.requested() + Notifier.ensureChannels(activity, channelNames()) + if (!distributors()) state.fellBack("NO_DISTRIBUTOR") + activity.runOnUiThread { + if (Build.VERSION.SDK_INT >= 33) { + activity.requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), PERMISSION_REQUEST) + } + // Only when one is installed: the system's chooser for a person who + // has none would be a screen about something they never asked for. + if (distributors()) UnifiedPush.tryUseDefaultDistributor(activity) { found -> + if (found) UnifiedPush.register(activity, messageForDistributor = "MeshBay") + else state.fellBack("NO_DISTRIBUTOR") + } + } + return status() + } + + private fun disable() { + Poller.cancel(activity) + try { UnifiedPush.removeDistributor(activity) } catch (e: Exception) { /* none was saved */ } + state.cleared() + } + + companion object { + private const val PERMISSION_REQUEST = 4207 + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt new file mode 100644 index 0000000..64ea7a7 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt @@ -0,0 +1,41 @@ +package org.meshbay.client.notify + +import android.content.Context +import android.util.Log +import org.meshbay.client.bridge.Bridge +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.PushService +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage + +/** + * What a distributor tells this application, page running or not — when the + * phone has one. Losing it is not an error: the phone goes back to fetching + * (`PollJob`), and the page tells the hub at its next start. + * + * A message is drawn only if the connector decrypted it with this phone's + * key: anything else did not come from a hub holding the subscription. + */ +class PushReceiver : PushService() { + private fun state() = PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE)) + + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { + state().endpoint(endpoint.url, endpoint.pubKeySet?.pubKey, endpoint.pubKeySet?.auth) + } + + override fun onMessage(message: PushMessage, instance: String) { + val state = state() + if (!message.decrypted || !state.enabled) { Log.w(Bridge.TAG, "push message dropped"); return } + Notifier.parse(message.content)?.let { Notifier.deliver(this, state, it) } + } + + override fun onRegistrationFailed(reason: FailedReason, instance: String) = fallBack(reason.name) + + override fun onUnregistered(instance: String) = fallBack("UNREGISTERED") + + private fun fallBack(reason: String) { + val state = state() + state.fellBack(reason) + if (state.enabled) Poller.schedule(this, pushed = false) + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt new file mode 100644 index 0000000..3905d58 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt @@ -0,0 +1,126 @@ +package org.meshbay.client.notify + +import android.content.SharedPreferences +import org.json.JSONObject + +/** + * Where this phone stands with the hub and, when it has one, with a push + * distributor — kept across restarts, because both answer whenever they like, + * often with the page not running. + * + * Turned on, it is always `ready` in the end: with an endpoint when a + * distributor gave one (pushed, instantly), without one otherwise (fetched, + * every quarter of an hour). Nothing to install is the default; a distributor + * is a bonus, and losing it falls back rather than failing. + * + * `registered` is the endpoint the hub was last given (null for none). When it + * differs from `endpoint` the page registers again; that is the whole of what + * keeps the hub's row current. + */ +class PushState(private val prefs: SharedPreferences) { + + fun status(): JSONObject = JSONObject() + .put("enabled", enabled) + .put("state", prefs.getString(STATE, OFF)) + .put("reason", prefs.getString(REASON, null) ?: JSONObject.NULL) + .put("endpoint", endpoint ?: JSONObject.NULL) + .put("p256dh", prefs.getString(P256DH, null) ?: JSONObject.NULL) + .put("auth", prefs.getString(AUTH, null) ?: JSONObject.NULL) + .put("subscription", prefs.getString(SUBSCRIPTION, null) ?: JSONObject.NULL) + .put("account", prefs.getString(ACCOUNT, null) ?: JSONObject.NULL) + .put("registered", prefs.getString(REGISTERED, null) ?: JSONObject.NULL) + + val enabled get() = prefs.getBoolean(ENABLED, false) + val endpoint: String? get() = prefs.getString(ENDPOINT, null) + + fun requested() = prefs.edit().putBoolean(ENABLED, true).putString(STATE, PENDING).remove(REASON).apply() + + fun endpoint(url: String, p256dh: String?, auth: String?) { + if (!enabled) return + // A distributor speaking only the old protocol gives no keys, and + // nothing could be encrypted to it: fetch instead. + if (p256dh == null || auth == null) { fellBack("NO_KEYS"); return } + prefs.edit().putString(STATE, READY).remove(REASON) + .putString(ENDPOINT, url).putString(P256DH, p256dh).putString(AUTH, auth).apply() + } + + /** No distributor, or it refused or dropped us: fetch, and say why. */ + fun fellBack(reason: String) { + if (!enabled) return + prefs.edit().putString(STATE, READY).putString(REASON, reason) + .remove(ENDPOINT).remove(P256DH).remove(AUTH).apply() + } + + /** What the hub answered a registration: its row, the account, the poll secret, its clock. */ + fun remember(subscription: String, account: String, secret: String, since: String) { + require(SUBSCRIPTION_ID.matches(subscription) && ACCOUNT_ID.matches(account) && + SECRET.matches(secret) && SINCE.matches(since)) { "bad subscription" } + prefs.edit().putString(SUBSCRIPTION, subscription).putString(ACCOUNT, account) + .putString(SECRET_KEY, secret).putString(SINCE_KEY, since) + .putString(REGISTERED, endpoint).apply() + } + + /** The hub no longer knows this row: the page registers again when it next runs. */ + fun forgetSubscription() = prefs.edit().remove(SUBSCRIPTION).remove(SECRET_KEY).remove(REGISTERED).apply() + + data class PollTarget(val id: String, val secret: String, val since: String) + + fun pollTarget(): PollTarget? { + if (!enabled) return null + return PollTarget(prefs.getString(SUBSCRIPTION, null) ?: return null, + prefs.getString(SECRET_KEY, null) ?: return null, + prefs.getString(SINCE_KEY, null) ?: return null) + } + + /** Fetch from here next time. ISO-8601 from the hub's own clock, so they compare as text. */ + fun advance(cursor: String) { + if (SINCE.matches(cursor) && cursor > (prefs.getString(SINCE_KEY, "") ?: "")) { + prefs.edit().putString(SINCE_KEY, cursor).apply() + } + } + + /** + * True the first time this line is seen at this date — pushed and then + * fetched, it is drawn once. A conversation keeps its id and moves its + * date, so a newer date is news again. + */ + @Synchronized + fun firstSight(id: Long, createdAt: String): Boolean { + val seen = try { JSONObject(prefs.getString(SEEN, "{}") ?: "{}") } catch (e: Exception) { JSONObject() } + val key = id.toString() + if (seen.optString(key, "") >= createdAt) return false + seen.put(key, createdAt) + if (seen.length() > SEEN_MAX) { + seen.keys().asSequence().toList().sortedBy { seen.optString(it) } + .take(seen.length() - SEEN_MAX).forEach { seen.remove(it) } + } + prefs.edit().putString(SEEN, seen.toString()).apply() + return true + } + + fun cleared() = prefs.edit().clear().apply() + + companion object { + const val OFF = "off" + const val PENDING = "pending" + const val READY = "ready" + private const val ENABLED = "enabled" + private const val STATE = "state" + private const val REASON = "reason" + private const val ENDPOINT = "endpoint" + private const val P256DH = "p256dh" + private const val AUTH = "auth" + private const val SUBSCRIPTION = "subscription" + private const val ACCOUNT = "account" + private const val REGISTERED = "registered" + private const val SECRET_KEY = "pollSecret" + private const val SINCE_KEY = "since" + private const val SEEN = "seen" + private const val SEEN_MAX = 100 + private val SUBSCRIPTION_ID = Regex("^[0-9a-f-]{36}$") + private val ACCOUNT_ID = Regex("^[0-9A-Za-z-]{1,64}$") + private val SECRET = Regex("^[A-Za-z0-9_-]{20,64}$") + private val SINCE = Regex("^\\d{4}-\\d\\d-\\d\\dT[0-9:.]+(\\+00:00|Z)$") + const val PREFS = "push" + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/BackupService.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/BackupService.kt new file mode 100644 index 0000000..b40d006 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/BackupService.kt @@ -0,0 +1,91 @@ +package org.meshbay.client.photos + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.wifi.WifiManager +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import org.meshbay.client.R + +/** + * Keeps a photo backup going with the screen off — the same pair as a cast + * (CastService): this service holds the process, the CPU and the Wi-Fi, and + * the shell keeps the WebView reported visible, because the sending happens in + * the page and Chromium freezes a hidden page after 60 s. + * + * Its own service, of type dataSync, rather than a second reason on the cast + * service: music can play during a backup, and each stops on its own. + * + * Started only from the page while the application is in front — a foreground + * service cannot be started from the background on Android 12+ — and its + * progress line is updated through the notification, which needs no start. + */ +class BackupService : Service() { + private var wake: PowerManager.WakeLock? = null + private var wifi: WifiManager.WifiLock? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val n = notification(this, intent?.getStringExtra(EXTRA_TEXT) ?: "") + if (Build.VERSION.SDK_INT >= 29) startForeground(ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + else startForeground(ID, n) + if (wake == null) { + wake = getSystemService(PowerManager::class.java) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "meshbay:backup").apply { acquire(MAX_HOLD_MS) } + @Suppress("DEPRECATION") + val mode = if (Build.VERSION.SDK_INT >= 29) WifiManager.WIFI_MODE_FULL_LOW_LATENCY else WifiManager.WIFI_MODE_FULL_HIGH_PERF + wifi = (applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager) + .createWifiLock(mode, "meshbay:backup").apply { acquire() } + } + return START_NOT_STICKY + } + + /** + * Android 15 gives dataSync six hours a day and then calls this; not + * stopping here is a crash. The run carries on with the screen on, or at + * the next opening, which is where an interrupted run goes anyway. + */ + override fun onTimeout(startId: Int, fgsType: Int) { + stopSelf() + } + + override fun onDestroy() { + wake?.let { if (it.isHeld) it.release() } + wifi?.let { if (it.isHeld) it.release() } + wake = null; wifi = null + super.onDestroy() + } + + companion object { + private const val ID = 8 + const val EXTRA_TEXT = "text" + private const val MAX_HOLD_MS = 6L * 3600 * 1000 + + fun notification(context: Context, text: String): Notification { + PhotoChannels.ensureChannel(context) + val open = PendingIntent.getActivity(context, ID, + context.packageManager.getLaunchIntentForPackage(context.packageName), PendingIntent.FLAG_IMMUTABLE) + return Notification.Builder(context, PhotoChannels.CHANNEL) + .setContentTitle("MeshBay") + .setContentText(text) + .setSmallIcon(R.drawable.ic_notify) + .setContentIntent(open) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build() + } + + /** The progress line of a running backup; nothing when none runs. */ + fun update(context: Context, text: String) { + val nm = context.getSystemService(NotificationManager::class.java) + if (nm.activeNotifications.any { it.id == ID }) nm.notify(ID, notification(context, text)) + } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt new file mode 100644 index 0000000..874f5f3 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt @@ -0,0 +1,298 @@ +package org.meshbay.client.photos + +import android.Manifest +import android.app.Activity +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.webkit.WebResourceResponse +import org.json.JSONArray +import org.json.JSONObject +import org.meshbay.client.MainActivity +import org.meshbay.client.R +import org.meshbay.client.bridge.Refused +import org.meshbay.client.notify.Notifier +import java.io.File +import java.io.FilterInputStream +import java.io.InputStream +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Photo backup (docs/MESHBAY_DESIGN.md §9.12): what the page needs from the + * phone, and nothing it could use to read anything else. + * + * The page decides when a run is due and does the sending, because the + * transport and the group key are there. This side lists the photos, keeps the + * ledger, and hands over bytes — by an opaque token the page fetches from the + * packaged origin (`/photosync/<token>`), valid for the run that issued it and + * for nothing but the photo it names. The page never sees a `content://` URI. + * + * Phone-only: the desktop preload has no counterpart, so `platform.photoSync` + * is absent there. + */ +class PhotoChannels( + private val activity: Activity, + private val prefs: SharedPreferences, + private val dir: File, + private val onKeepAlive: (Boolean, String) -> Unit, +) { + private val source = PhotoSource(activity) + private val random = SecureRandom() + private val tokens = ConcurrentHashMap<String, Issued>() + @Volatile private var permission: CountDownLatch? = null + + private class Issued(val pending: Pending, val key: String) { + @Volatile var sha256: String? = null + } + + fun handles(channel: String) = channel.startsWith("photosync:") + + fun call(channel: String, args: JSONArray): Any? = when (channel) { + "photosync:status" -> status() + "photosync:permit" -> { permit(); status() } + "photosync:albums" -> { requirePermission(); albums() } + "photosync:configure" -> { configure(args.optJSONObject(0)); status() } + "photosync:estimate" -> { requirePermission(); estimate(args.optJSONObject(0) ?: throw Refused("Refused: no settings")) } + "photosync:plan" -> { requirePermission(); plan() } + "photosync:sent" -> { sent(args.optString(0, ""), args.optString(1, ""), args.optString(2, "")); true } + "photosync:completed" -> { completed(); status() } + "photosync:failed" -> failed(args.optString(0, ""), args.optString(1, "")) + "photosync:keep-alive" -> { onKeepAlive(args.optBoolean(0, false), args.optString(1, "").take(200)); true } + else -> throw Refused("Refused: no such channel") + } + + // ── state ──────────────────────────────────────────────────────────────── + + private fun config(): SyncConfig? = SyncConfig.parse(prefs.getString(CONFIG, null)) + + private fun ledger(c: SyncConfig) = PhotoLedger(File(dir, hex(sha256Of(c.ledgerKey.toByteArray())).take(32) + ".jsonl")) + + fun status(): JSONObject { + val c = config() + return JSONObject() + .put("permission", permissionState()) + .put("unmetered", unmetered()) + .put("config", c?.toJson() ?: JSONObject.NULL) + .put("lastCompleted", if (prefs.contains(LAST)) prefs.getLong(LAST, 0) else JSONObject.NULL) + .put("failure", prefs.getString(FAILURE, null) ?: JSONObject.NULL) + .put("failureAt", if (prefs.contains(FAILURE_AT)) prefs.getLong(FAILURE_AT, 0) else JSONObject.NULL) + .put("sent", c?.let { ledger(it).size } ?: 0) + .put("now", System.currentTimeMillis()) + } + + private fun configure(o: JSONObject?) { + val previous = config() + if (o == null) { + prefs.edit().remove(CONFIG).remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply() + tokens.clear() + return + } + val next = try { SyncConfig.fromJson(o, System.currentTimeMillis(), previous) } + catch (e: IllegalArgumentException) { throw Refused("Refused: ${e.message}") } + val edit = prefs.edit().putString(CONFIG, next.toJson().toString()) + // A different destination or scope is a different backup: due at once, + // and whatever the last one was refused for is not this one's problem. + if (previous == null || previous.ledgerKey != next.ledgerKey || previous.since != next.since) { + edit.remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED) + } + edit.apply() + tokens.clear() + } + + private fun completed() { + prefs.edit().putLong(LAST, System.currentTimeMillis()).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply() + } + + /** + * A run stopped for a reason that will hold tomorrow too — the folder is no + * longer writable, the disk is full, the person left the group. Said once, + * in a notification, rather than every day; true when this call said it. + */ + private fun failed(code: String, text: String): Boolean { + val c = code.take(64) + prefs.edit().putString(FAILURE, c).putLong(FAILURE_AT, System.currentTimeMillis()).apply() + if (prefs.getString(NOTIFIED, null) == c || text.isBlank()) return false + prefs.edit().putString(NOTIFIED, c).apply() + notify(text.take(300)) + return true + } + + // ── the phone's photos ─────────────────────────────────────────────────── + + private fun albums(): JSONArray = JSONArray().apply { + for (a in source.albums()) put(JSONObject().put("id", a.id).put("name", a.name) + .put("count", a.count).put("bytes", a.bytes).put("camera", a.camera)) + } + + /** What a backup set up this way would send first: the count and size the confirmation states. */ + private fun estimate(o: JSONObject): JSONObject { + val c = try { SyncConfig.fromJson(o, System.currentTimeMillis(), config()) } + catch (e: IllegalArgumentException) { throw Refused("Refused: ${e.message}") } + val ledger = ledger(c) + val items = PhotoPlan.plan(source.photos(c.albums), c, ledger::get) { _, _ -> true } + return JSONObject().put("count", items.size).put("bytes", items.sumOf { it.photo.size }) + } + + private fun plan(): JSONObject { + val c = config() ?: throw Refused("Refused: photo backup is off") + val ledger = ledger(c) + val items = PhotoPlan.plan(source.photos(c.albums), c, ledger::get) { p, sent -> + hashOf(p.mediaId)?.let { it == sent.sha256 } ?: true + } + // A new plan replaces the last one: tokens are for one run, never kept. + tokens.clear() + val out = JSONArray() + for (p in items) { + val token = hex(ByteArray(16).also { random.nextBytes(it) }) + tokens[token] = Issued(p, c.ledgerKey) + out.put(JSONObject().put("token", token).put("name", p.name).put("dir", p.dir) + .put("size", p.photo.size).put("edited", p.edited).put("taken", PhotoPlan.whenTaken(p.photo)) + // After a reinstall the ledger is empty, and the page looks in the + // folder for what is already there — an edit under its own name too. + .put("alsoKnownAs", PhotoPlan.editedName(p.photo, java.util.TimeZone.getDefault()))) + } + return JSONObject().put("items", out) + } + + /** The node took it (or already had it): into the ledger, under the name its ack gave. */ + private fun sent(token: String, dir: String, name: String) { + val issued = tokens[token] ?: throw Refused("Refused: unknown photo") + val c = config()?.takeIf { it.ledgerKey == issued.key } ?: throw Refused("Refused: the backup changed") + val p = issued.pending.photo + val sha = issued.sha256 ?: hashOf(p.mediaId) ?: throw Refused("Refused: the photo is gone") + ledger(c).record(PhotoLedger.Entry(p.mediaId, p.modified, p.size, sha, + dir.take(1024), name.take(256), System.currentTimeMillis())) + tokens.remove(token) + } + + /** + * The bytes of an issued photo, for `/photosync/<token>` on the packaged + * origin. Hashed as they go out, so the ledger records exactly what was sent. + */ + fun serve(path: String): WebResourceResponse? { + val issued = tokens[path.removePrefix(PATH)] ?: return null + val raw = try { source.open(issued.pending.photo.mediaId) } catch (e: Exception) { null } ?: return null + val digest = MessageDigest.getInstance("SHA-256") + val stream = object : FilterInputStream(raw) { + private var done = false + override fun read(): Int = super.read().also { if (it < 0) finish() else digest.update(it.toByte()) } + override fun read(b: ByteArray, off: Int, len: Int): Int = + super.read(b, off, len).also { if (it < 0) finish() else digest.update(b, off, it) } + private fun finish() { if (!done) { done = true; issued.sha256 = hex(digest.digest()) } } + } + val headers = mapOf("Cache-Control" to "no-store", "X-Content-Type-Options" to "nosniff") + return WebResourceResponse(issued.pending.photo.mime.ifEmpty { "application/octet-stream" }, + null, 200, "OK", headers, stream) + } + + private fun hashOf(mediaId: Long): String? = try { + source.open(mediaId)?.use { s -> hex(digestOf(s)) } + } catch (e: Exception) { null } + + // ── permission and network ─────────────────────────────────────────────── + + private fun permissionState(): String { + fun has(p: String) = activity.checkSelfPermission(p) == PackageManager.PERMISSION_GRANTED + return when { + Build.VERSION.SDK_INT >= 33 && has(Manifest.permission.READ_MEDIA_IMAGES) -> "granted" + Build.VERSION.SDK_INT >= 34 && has(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) -> "partial" + Build.VERSION.SDK_INT < 33 && has(Manifest.permission.READ_EXTERNAL_STORAGE) -> "granted" + else -> "denied" + } + } + + private fun requirePermission() { + if (permissionState() == "denied") throw Refused("Refused: no access to photos") + } + + /** Asks, and waits for the answer: the page goes on from what was decided. */ + private fun permit() { + val wanted = when { + Build.VERSION.SDK_INT >= 34 -> arrayOf(Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) + Build.VERSION.SDK_INT >= 33 -> arrayOf(Manifest.permission.READ_MEDIA_IMAGES) + else -> arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + val latch = CountDownLatch(1) + permission = latch + activity.runOnUiThread { activity.requestPermissions(wanted, PERMISSION_REQUEST) } + latch.await(5, TimeUnit.MINUTES) + permission = null + } + + /** From Activity.onRequestPermissionsResult; true when the request was ours. */ + fun deliverPermission(requestCode: Int): Boolean { + if (requestCode != PERMISSION_REQUEST) return false + permission?.countDown() + return true + } + + /** + * Not "on Wi-Fi": a phone joined to another phone's hotspot is on Wi-Fi and + * spending that phone's mobile data, and Android reports it as metered. + */ + fun unmetered(): Boolean { + val cm = activity.getSystemService(ConnectivityManager::class.java) + val caps = cm.getNetworkCapabilities(cm.activeNetwork ?: return false) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) || + (Build.VERSION.SDK_INT >= 30 && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED)) + } + + private fun notify(text: String) { + val nm = activity.getSystemService(NotificationManager::class.java) + ensureChannel(activity) + val open = Intent(activity, MainActivity::class.java).setAction(Intent.ACTION_VIEW) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + .putExtra(Notifier.EXTRA_LINK, "#/settings") + val pending = PendingIntent.getActivity(activity, NOTIFY_ID, open, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + nm.notify(NOTIFY_ID, Notification.Builder(activity, CHANNEL) + .setSmallIcon(R.drawable.ic_notify).setContentTitle("MeshBay").setContentText(text) + .setStyle(Notification.BigTextStyle().bigText(text)) + .setContentIntent(pending).setAutoCancel(true).build()) + } + + companion object { + const val PATH = "/photosync/" + const val CHANNEL = "backup" + private const val NOTIFY_ID = 9 + private const val PERMISSION_REQUEST = 4208 + const val PREFS = "photosync" + private const val CONFIG = "config" + private const val LAST = "last_completed" + private const val FAILURE = "failure" + private const val FAILURE_AT = "failure_at" + private const val NOTIFIED = "notified" + + fun ensureChannel(context: Context) { + val nm = context.getSystemService(NotificationManager::class.java) + if (nm.getNotificationChannel(CHANNEL) == null) { + nm.createNotificationChannel(NotificationChannel(CHANNEL, "Photo backup", NotificationManager.IMPORTANCE_LOW)) + } + } + + fun digestOf(s: InputStream): ByteArray { + val d = MessageDigest.getInstance("SHA-256") + val buf = ByteArray(64 * 1024) + while (true) { val n = s.read(buf); if (n < 0) break; d.update(buf, 0, n) } + return d.digest() + } + + fun sha256Of(b: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(b) + + fun hex(b: ByteArray): String = b.joinToString("") { "%02x".format(it) } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt new file mode 100644 index 0000000..f3aa6e5 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt @@ -0,0 +1,91 @@ +package org.meshbay.client.photos + +import org.json.JSONObject +import java.io.File + +/** + * What this phone has sent to one folder of one group — the memory of what was + * sent, never a mirror of the phone (docs/MESHBAY_DESIGN.md §9.12). + * + * A run sends what is not here, and nothing compares in the other direction: a + * photo deleted on the phone simply stops being listed, and one deleted on the + * node stays here and is not sent again — deleting it there was a decision. + * + * One line of JSON per send, appended; the last line for a media id wins. A + * whole-file rewrite per photo would be megabytes written per photo on a roll + * of twenty thousand. Compacted on load once the dead lines outnumber the live + * ones. Plain files and org.json, so the JVM tests run it as it runs here. + */ +class PhotoLedger(private val file: File) { + + data class Entry( + val mediaId: Long, + /** MediaStore DATE_MODIFIED, seconds — what tells an edit from the photo sent. */ + val modified: Long, + val size: Long, + /** SHA-256 of the bytes sent, hex: an edit is sent only if this changed. */ + val sha256: String, + /** Where the node put it: the folder and the name its ack gave. */ + val dir: String, + val name: String, + val sentAt: Long, + ) + + private val entries = HashMap<Long, Entry>() + private var lines = 0 + + init { load() } + + val size: Int get() = entries.size + + operator fun get(mediaId: Long): Entry? = entries[mediaId] + + fun all(): Collection<Entry> = entries.values + + fun record(entry: Entry) { + entries[entry.mediaId] = entry + file.parentFile?.mkdirs() + file.appendText(encode(entry) + "\n") + lines += 1 + } + + /** Everything forgotten — the group or the folder changed, or backup was turned off. */ + fun clear() { + entries.clear() + lines = 0 + file.delete() + } + + private fun load() { + if (!file.exists()) return + file.forEachLine { line -> + if (line.isBlank()) return@forEachLine + lines += 1 + // A line cut short by a process killed mid-write is the only kind + // that fails to parse; what it was recording is sent again, once. + decode(line)?.let { entries[it.mediaId] = it } + } + if (lines > 2 * entries.size + COMPACT_SLACK) compact() + } + + private fun compact() { + val tmp = File(file.path + ".tmp") + tmp.writeText(entries.values.joinToString("") { encode(it) + "\n" }) + if (!tmp.renameTo(file)) { tmp.delete(); return } + lines = entries.size + } + + companion object { + private const val COMPACT_SLACK = 64 + + fun encode(e: Entry): String = JSONObject() + .put("id", e.mediaId).put("m", e.modified).put("s", e.size).put("h", e.sha256) + .put("d", e.dir).put("n", e.name).put("t", e.sentAt).toString() + + fun decode(line: String): Entry? = try { + val o = JSONObject(line) + Entry(o.getLong("id"), o.getLong("m"), o.getLong("s"), o.getString("h"), + o.getString("d"), o.getString("n"), o.getLong("t")) + } catch (e: Exception) { null } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt new file mode 100644 index 0000000..de39669 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt @@ -0,0 +1,167 @@ +package org.meshbay.client.photos + +import org.json.JSONArray +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** One image in MediaStore, as much of it as a plan needs. */ +data class Photo( + val mediaId: Long, + val displayName: String, + val size: Long, + /** Milliseconds; 0 when the camera wrote none. */ + val taken: Long, + /** MediaStore DATE_ADDED and DATE_MODIFIED, seconds. */ + val added: Long, + val modified: Long, + val bucketId: String, + val mime: String, +) + +/** + * Where this phone's photos go and which of them, as the person set it up. + * One group per phone (docs/MESHBAY_DESIGN.md §9.12). + */ +data class SyncConfig( + val account: String, + val groupId: String, + val groupName: String, + val owner: String, + /** A folder among the group's own, as a virtual path (`Photos/Family`). */ + val folder: String, + val albums: List<String>, + /** The photos already on the phone too — the default, as backup applications do. */ + val includeExisting: Boolean, + /** When it was set up, ms: with `includeExisting` off, only photos added since count. */ + val since: Long, +) { + /** The ledger belongs to this, so a different group or folder starts a fresh one. */ + val ledgerKey: String get() = "$account\n$groupId\n$folder" + + fun toJson(): JSONObject = JSONObject() + .put("account", account).put("groupId", groupId).put("groupName", groupName) + .put("owner", owner).put("folder", folder).put("albums", JSONArray(albums)) + .put("includeExisting", includeExisting).put("since", since) + + companion object { + /** From the page, so checked like any input: names it chose, never a path on this phone. */ + fun fromJson(o: JSONObject, now: Long, previous: SyncConfig? = null): SyncConfig { + val account = o.optString("account", "").take(64) + val groupId = o.optString("groupId", "").take(64) + val folder = o.optString("folder", "").trim().trim('/').take(1024) + require(account.isNotEmpty() && groupId.isNotEmpty() && folder.isNotEmpty()) { "incomplete" } + require(folder.split('/').none { it.isEmpty() || it == "." || it == ".." }) { "bad folder" } + val albums = o.optJSONArray("albums") ?: JSONArray() + val includeExisting = o.optBoolean("includeExisting", true) + // A change of destination or of scope starts again from that moment; + // a change of album list alone does not move it. + val same = previous != null && previous.account == account && previous.groupId == groupId && + previous.folder == folder && previous.includeExisting == includeExisting + return SyncConfig( + account, groupId, + o.optString("groupName", "").take(256), o.optString("owner", "").take(64), + folder, + (0 until albums.length()).map { albums.optString(it, "").take(64) }.filter { it.isNotEmpty() }.distinct(), + includeExisting, + if (same) previous!!.since else now, + ) + } + + fun parse(text: String?): SyncConfig? = try { + val o = JSONObject(text ?: return null) + val albums = o.getJSONArray("albums") + SyncConfig(o.getString("account"), o.getString("groupId"), o.optString("groupName"), + o.optString("owner"), o.getString("folder"), + (0 until albums.length()).map { albums.getString(it) }, + o.optBoolean("includeExisting", true), o.getLong("since")) + } catch (e: Exception) { null } + } +} + +/** A photo to send: a new one, or a new version of one already sent. */ +data class Pending(val photo: Photo, val edited: Boolean, val dir: String, val name: String) + +/** + * What a run sends, decided from the phone's photos and the ledger alone. + * + * Pure, so the rules are tested on the JVM; reading bytes for an edit's hash is + * the caller's (`sameBytes`). + */ +object PhotoPlan { + /** A run is due once a day, counted from the last one that finished. */ + const val DAY_MS = 24L * 3600 * 1000 + + fun due(lastCompleted: Long?, now: Long): Boolean = + lastCompleted == null || now - lastCompleted >= DAY_MS || now < lastCompleted + + /** + * `sameBytes(photo, entry)` is asked only of a photo whose MediaStore + * modification date moved since it was sent: true when its bytes are still + * those the ledger hashed (a favourite flag, a rescan), in which case + * nothing is sent. + */ + fun plan( + photos: List<Photo>, config: SyncConfig, ledger: (Long) -> PhotoLedger.Entry?, + zone: TimeZone = TimeZone.getDefault(), + sameBytes: (Photo, PhotoLedger.Entry) -> Boolean, + ): List<Pending> { + val albums = config.albums.toSet() + val out = ArrayList<Pending>() + for (p in photos) { + if (p.bucketId !in albums) continue + if (!p.mime.startsWith("image/")) continue + val sent = ledger(p.mediaId) + if (sent == null) { + if (!config.includeExisting && p.added * 1000 < config.since) continue + out += Pending(p, false, dirFor(config.folder, p, zone), nameFor(p)) + } else if (sent.modified != p.modified || sent.size != p.size) { + if (sent.size == p.size && sameBytes(p, sent)) continue + out += Pending(p, true, dirFor(config.folder, p, zone), editedName(p, zone)) + } + } + // Newest first: the photos most likely to exist nowhere else are safe earliest. + return out.sortedByDescending { whenTaken(it.photo) } + } + + fun whenTaken(p: Photo): Long = if (p.taken > 0) p.taken else p.added * 1000 + + /** `<folder>/YYYY/MM`, from when it was taken: an album is a directory (§9.9). */ + fun dirFor(folder: String, p: Photo, zone: TimeZone): String { + val fmt = SimpleDateFormat("yyyy/MM", Locale.ROOT).apply { timeZone = zone } + return "$folder/${fmt.format(Date(whenTaken(p)))}" + } + + fun nameFor(p: Photo): String = + p.displayName.takeIf { UPLOAD_NAME.matches(it) } ?: "photo-${p.mediaId}.${extension(p)}" + + /** + * An edit lands beside the original under a name that says what it is; left + * to the node it would be `IMG_…(1).jpg`, which says nothing. + */ + fun editedName(p: Photo, zone: TimeZone): String { + val base = nameFor(p) + val dot = base.lastIndexOf('.') + val stem = if (dot > 0) base.substring(0, dot) else base + val ext = if (dot > 0) base.substring(dot) else "" + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).apply { timeZone = zone } + .format(Date(p.modified * 1000)) + val suffix = "-edited-$stamp$ext" + return stem.take(MAX_NAME - suffix.length) + suffix + } + + private fun extension(p: Photo): String = + p.displayName.substringAfterLast('.', "").lowercase(Locale.ROOT).takeIf { it.matches(Regex("^[a-z0-9]{1,5}$")) } + ?: when (p.mime) { "image/png" -> "png"; "image/heic" -> "heic"; "image/heif" -> "heif" + "image/webp" -> "webp"; "image/gif" -> "gif"; else -> "jpg" } + + private const val MAX_NAME = 128 + + /** + * The node's SAFE_UPLOAD_NAME (roots.py), as files-app.js copies it. A name it + * refuses would fail the upload; this one is renamed before it is sent. + */ + val UPLOAD_NAME = Regex("^[\\p{L}\\p{N}][\\p{L}\\p{N}_ .\\-()\\[\\]'’,&+#@]{0,127}(?<![ .])$") +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt new file mode 100644 index 0000000..f64612f --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt @@ -0,0 +1,84 @@ +package org.meshbay.client.photos + +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import java.io.InputStream + +/** + * The phone's photos, through MediaStore and nothing else. + * + * Opened through MediaStore by an application without ACCESS_MEDIA_LOCATION, + * a photo's EXIF location is **redacted by the platform** (Android 10+): a whole + * camera roll going to several people does not say where its owner lives, and + * nobody had to do anything for that. The manifest does not ask for it. + */ +class PhotoSource(private val context: Context) { + + data class Album(val id: String, val name: String, val count: Int, val bytes: Long, val camera: Boolean) + + private val collection: Uri = + if (Build.VERSION.SDK_INT >= 29) MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) + else MediaStore.Images.Media.EXTERNAL_CONTENT_URI + + fun albums(): List<Album> { + val by = LinkedHashMap<String, Album>() + query(null, null) { p, name, camera -> + val a = by[p.bucketId] + by[p.bucketId] = if (a == null) Album(p.bucketId, name, 1, p.size, camera) + else a.copy(count = a.count + 1, bytes = a.bytes + p.size, camera = a.camera || camera) + } + return by.values.sortedWith(compareByDescending<Album> { it.camera }.thenByDescending { it.count }) + } + + fun photos(albums: List<String>): List<Photo> { + if (albums.isEmpty()) return emptyList() + val out = ArrayList<Photo>() + val where = "${MediaStore.Images.Media.BUCKET_ID} IN (${albums.joinToString(",") { "?" }})" + query(where, albums.toTypedArray()) { p, _, _ -> out += p } + return out + } + + fun open(mediaId: Long): InputStream? = + context.contentResolver.openInputStream(ContentUris.withAppendedId(collection, mediaId)) + + private fun query(where: String?, args: Array<String>?, each: (Photo, String, Boolean) -> Unit) { + val cols = mutableListOf( + MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, + MediaStore.Images.Media.DATE_TAKEN, MediaStore.Images.Media.DATE_ADDED, + MediaStore.Images.Media.DATE_MODIFIED, MediaStore.Images.Media.BUCKET_ID, + MediaStore.Images.Media.BUCKET_DISPLAY_NAME, MediaStore.Images.Media.MIME_TYPE, + ) + @Suppress("DEPRECATION") + val location = if (Build.VERSION.SDK_INT >= 29) MediaStore.Images.Media.RELATIVE_PATH else MediaStore.Images.Media.DATA + cols += location + // A photo still being written by the camera is not a photo yet. + val pending = if (Build.VERSION.SDK_INT >= 29) "${MediaStore.Images.Media.IS_PENDING} = 0" else null + val selection = listOfNotNull(pending, where).joinToString(" AND ").ifEmpty { null } + context.contentResolver.query(collection, cols.toTypedArray(), selection, args, null)?.use { c -> + val id = c.getColumnIndexOrThrow(cols[0]); val name = c.getColumnIndexOrThrow(cols[1]) + val size = c.getColumnIndexOrThrow(cols[2]); val taken = c.getColumnIndexOrThrow(cols[3]) + val added = c.getColumnIndexOrThrow(cols[4]); val modified = c.getColumnIndexOrThrow(cols[5]) + val bucket = c.getColumnIndexOrThrow(cols[6]); val bucketName = c.getColumnIndexOrThrow(cols[7]) + val mime = c.getColumnIndexOrThrow(cols[8]); val where2 = c.getColumnIndexOrThrow(cols[9]) + while (c.moveToNext()) { + val bucketId = c.getString(bucket) ?: continue + val photo = Photo( + c.getLong(id), c.getString(name) ?: "", c.getLong(size), + if (c.isNull(taken)) 0 else c.getLong(taken), c.getLong(added), c.getLong(modified), + bucketId, c.getString(mime) ?: "", + ) + val path = (c.getString(where2) ?: "").replace('\\', '/') + each(photo, c.getString(bucketName) ?: "", isCamera(path)) + } + } + } + + companion object { + /** `DCIM/Camera/` (RELATIVE_PATH) or `…/DCIM/Camera/x.jpg` (DATA, before Android 10). */ + fun isCamera(path: String): Boolean = + path.startsWith("DCIM/Camera") || path.contains("/DCIM/Camera/") + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt index 731da69..f3eb84e 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt @@ -8,9 +8,9 @@ import android.webkit.WebView * While `keepVisible` is set, the WebView is told its window stayed visible * when the screen turns off. Chromium then never marks the page hidden, and * its freeze of hidden pages — exactly 60 s after hiding, measured (spike - * S-2a C) — never starts. Set only while a cast runs or music plays: a page that is never - * hidden is never throttled, which is the battery cost the freeze exists to - * avoid. + * S-2a C) — never starts. Set only while a cast runs, music plays or photos + * are being backed up: a page that is never hidden is never throttled, which + * is the battery cost the freeze exists to avoid. */ class ShellWebView(context: Context) : WebView(context) { var keepVisible = false diff --git a/packages/meshbay-android/app/src/main/res/drawable/ic_notify.xml b/packages/meshbay-android/app/src/main/res/drawable/ic_notify.xml new file mode 100644 index 0000000..ec26359 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/drawable/ic_notify.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- The status-bar icon: the system keeps only its alpha, so a plain M. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FFFFFFFF" + android:pathData="M3,20V4h3l6,8 6,-8h3v16h-3V9l-6,8 -6,-8v11z" /> +</vector> diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt new file mode 100644 index 0000000..24e1841 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt @@ -0,0 +1,182 @@ +package org.meshbay.client + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.meshbay.client.photos.Photo +import org.meshbay.client.photos.PhotoLedger +import org.meshbay.client.photos.PhotoPlan +import org.meshbay.client.photos.SyncConfig +import java.util.TimeZone + +class PhotoSyncTest { + @get:Rule val tmp = TemporaryFolder() + + private val utc = TimeZone.getTimeZone("UTC") + // 2026-10-09 12:00:00 UTC + private val oct9 = 1791547200000L + + private fun photo(id: Long, name: String = "IMG_$id.jpg", size: Long = 100, taken: Long = oct9, + added: Long = oct9 / 1000, modified: Long = oct9 / 1000, bucket: String = "cam", + mime: String = "image/jpeg") = + Photo(id, name, size, taken, added, modified, bucket, mime) + + private fun config(includeExisting: Boolean = true, since: Long = 0, albums: List<String> = listOf("cam")) = + SyncConfig("bob", "g1", "Family", "alice", "Media/Photos/Bob", albums, includeExisting, since) + + private fun entry(p: Photo, sha: String = "h", name: String = p.displayName) = + PhotoLedger.Entry(p.mediaId, p.modified, p.size, sha, "d", name, 1) + + private fun plan(photos: List<Photo>, c: SyncConfig = config(), ledger: Map<Long, PhotoLedger.Entry> = emptyMap(), + same: Boolean = false) = + PhotoPlan.plan(photos, c, { ledger[it] }, utc) { _, _ -> same } + + // ── what is sent ────────────────────────────────────────────────────────── + + @Test fun `everything already on the phone is sent, newest first, under its year and month`() { + val out = plan(listOf(photo(1, taken = oct9 - 40L * 86400000), photo(2), photo(3, taken = oct9 - 86400000))) + assertEquals(listOf(2L, 3L, 1L), out.map { it.photo.mediaId }) + assertEquals("Media/Photos/Bob/2026/10", out[0].dir) + assertEquals("Media/Photos/Bob/2026/08", out[2].dir) + assertEquals("IMG_2.jpg", out[0].name) + } + + @Test fun `from now on leaves out what was on the phone before`() { + val since = oct9 + 1000 + val out = plan(listOf(photo(1), photo(2, added = (oct9 + 5000) / 1000)), config(includeExisting = false, since = since)) + assertEquals(listOf(2L), out.map { it.photo.mediaId }) + } + + @Test fun `only the chosen albums, and only images`() { + val out = plan(listOf(photo(1), photo(2, bucket = "screens"), photo(3, mime = "video/mp4"))) + assertEquals(listOf(1L), out.map { it.photo.mediaId }) + } + + @Test fun `a photo already sent is not sent again`() { + val p = photo(1) + assertTrue(plan(listOf(p), ledger = mapOf(1L to entry(p))).isEmpty()) + } + + @Test fun `a photo deleted on the phone is simply not listed, and nothing is asked of the node`() { + // The plan has only additions in it: there is no other kind of item. + val sent = photo(1) + assertTrue(plan(emptyList(), ledger = mapOf(1L to entry(sent))).isEmpty()) + } + + // ── edits ──────────────────────────────────────────────────────────────── + + @Test fun `an edit is sent beside the original under a name that says so`() { + val before = photo(1) + val after = before.copy(size = 120, modified = before.modified + 3600) + val out = plan(listOf(after), ledger = mapOf(1L to entry(before))) + assertEquals(1, out.size) + assertTrue(out[0].edited) + assertEquals("IMG_1-edited-20261009-130000.jpg", out[0].name) + assertEquals("Media/Photos/Bob/2026/10", out[0].dir) + } + + @Test fun `a touch that left the bytes alone sends nothing`() { + val before = photo(1) + val touched = before.copy(modified = before.modified + 60) + assertTrue(plan(listOf(touched), ledger = mapOf(1L to entry(before)), same = true).isEmpty()) + assertEquals(1, plan(listOf(touched), ledger = mapOf(1L to entry(before)), same = false).size) + } + + @Test fun `the bytes are only read when the date moved and the size did not`() { + val before = photo(1) + var asked = 0 + PhotoPlan.plan(listOf(before), config(), { entry(before) }, utc) { _, _ -> asked++; true } + PhotoPlan.plan(listOf(before.copy(size = 7, modified = 9)), config(), { entry(before) }, utc) { _, _ -> asked++; true } + assertEquals(0, asked) + } + + // ── names ──────────────────────────────────────────────────────────────── + + @Test fun `a name the node would refuse is replaced before it is sent`() { + assertEquals("IMG_1.jpg", PhotoPlan.nameFor(photo(1))) + assertEquals("photo-7.jpg", PhotoPlan.nameFor(photo(7, name = ".hidden.jpg"))) + assertEquals("photo-8.png", PhotoPlan.nameFor(photo(8, name = "_x.png"))) + assertEquals("photo-9.jpg", PhotoPlan.nameFor(photo(9, name = ""))) + assertTrue(PhotoPlan.UPLOAD_NAME.matches("PXL_20261009_120000123.jpg")) + assertFalse(PhotoPlan.UPLOAD_NAME.matches("a.jpg.")) + } + + @Test fun `an edited name stays within the node's length`() { + val long = photo(1, name = "A".repeat(124) + ".jpg", modified = 1) + val name = PhotoPlan.editedName(long, utc) + assertTrue(name.length <= 128) + assertTrue(PhotoPlan.UPLOAD_NAME.matches(name)) + } + + // ── when ───────────────────────────────────────────────────────────────── + + @Test fun `once a day, counted from the last run that finished`() { + assertTrue(PhotoPlan.due(null, oct9)) + assertFalse(PhotoPlan.due(oct9 - 3600_000, oct9)) + assertTrue(PhotoPlan.due(oct9 - PhotoPlan.DAY_MS, oct9)) + assertTrue("a clock moved back must not stop backups for good", PhotoPlan.due(oct9 + 3600_000, oct9)) + } + + // ── settings from the page ─────────────────────────────────────────────── + + @Test fun `a folder that is not one of the group's own is refused`() { + for (bad in listOf("", "../etc", "Media/../x", "Media//x", "/")) { + val o = JSONObject().put("account", "bob").put("groupId", "g1").put("folder", bad) + assertThrows(bad, IllegalArgumentException::class.java) { SyncConfig.fromJson(o, 0) } + } + } + + @Test fun `changing the albums keeps the starting point, changing the group moves it`() { + val o = JSONObject().put("account", "bob").put("groupId", "g1").put("folder", "Media/Photos") + .put("albums", JSONArray(listOf("cam"))).put("includeExisting", false) + val first = SyncConfig.fromJson(o, 100) + assertEquals(100, SyncConfig.fromJson(o.put("albums", JSONArray(listOf("cam", "x"))), 200, first).since) + assertEquals(300, SyncConfig.fromJson(o.put("groupId", "g2"), 300, first).since) + } + + @Test fun `settings survive being stored`() { + val c = config(albums = listOf("a", "b")) + assertEquals(c, SyncConfig.parse(c.toJson().toString())) + assertNull(SyncConfig.parse("{}")) + } + + // ── the ledger ─────────────────────────────────────────────────────────── + + @Test fun `the ledger remembers across a restart, last line wins`() { + val f = tmp.newFile("l.jsonl") + PhotoLedger(f).apply { + record(entry(photo(1), sha = "a")) + record(entry(photo(2), sha = "b")) + record(entry(photo(1), sha = "c")) + } + val again = PhotoLedger(f) + assertEquals(2, again.size) + assertEquals("c", again[1]!!.sha256) + } + + @Test fun `a line cut short by a killed process costs that one photo, not the ledger`() { + val f = tmp.newFile("l.jsonl") + PhotoLedger(f).record(entry(photo(1))) + f.appendText("{\"id\":2,\"m\":") + val again = PhotoLedger(f) + assertEquals(1, again.size) + assertNull(again[2]) + } + + @Test fun `a ledger rewritten many times is compacted on load`() { + val f = tmp.newFile("l.jsonl") + val l = PhotoLedger(f) + repeat(300) { l.record(entry(photo(1), sha = "s$it")) } + val again = PhotoLedger(f) + assertEquals(1, again.size) + assertEquals("s299", again[1]!!.sha256) + assertEquals(1, f.readLines().count { it.isNotBlank() }) + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt new file mode 100644 index 0000000..c575906 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt @@ -0,0 +1,108 @@ +package org.meshbay.client + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.meshbay.client.notify.Notifier +import org.meshbay.client.notify.PushState + +class PushTest { + private fun payload(vararg pairs: Pair<String, Any?>) = + JSONObject().apply { put("v", 1); pairs.forEach { (k, v) -> put(k, v ?: JSONObject.NULL) } } + .toString().toByteArray() + + @Test fun `a conversation is one entry per group, anything else one per notification`() { + val chat = Notifier.parse(payload("id" to 7, "kind" to "chat_message", "title" to "a posted in b", + "group_id" to "g-1", "link" to "#/group/g-1"))!! + assertEquals(Notifier.CHANNEL_CHAT, chat.channel) + assertEquals("chat:g-1", chat.tag) + assertEquals("#/group/g-1", chat.link) + val invite = Notifier.parse(payload("id" to 8, "kind" to "group_invite", "title" to "x invited you", + "group_id" to null, "link" to "#/"))!! + assertEquals(Notifier.CHANNEL_OTHER, invite.channel) + assertEquals("n:8", invite.tag) + } + + @Test fun `a link that is not a route inside the page is dropped, not followed`() { + for (bad in listOf("https://elsewhere.example/", "javascript:alert(1)", "#/x\";alert(1)//", "//host/#/")) { + val shown = Notifier.parse(payload("id" to 1, "kind" to "k", "title" to "t", "link" to bad))!! + assertNull(bad, shown.link) + } + } + + @Test fun `what is not ours to draw is not drawn`() { + assertNull(Notifier.parse("not json".toByteArray())) + assertNull(Notifier.parse(JSONObject().put("v", 2).put("title", "t").toString().toByteArray())) + assertNull(Notifier.parse(payload("id" to 1, "kind" to "k", "title" to " "))) + } + + private val sub = "0f8fad5b-d9cb-469f-a165-70867728950e" + private val account = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val secret = "Zm9vYmFyYmF6cXV4X3NlY3JldF92YWx1ZQ" + private val since = "2026-10-09T10:00:00.123456+00:00" + + @Test fun `the hub is registered again when the distributor hands out a new endpoint`() { + val state = PushState(FakePrefs()) + state.endpoint("https://push.example.net/1", "k", "a") // not asked for: ignored + assertEquals(PushState.OFF, state.status().getString("state")) + state.requested() + state.endpoint("https://push.example.net/1", "k", "a") + state.remember(sub, account, secret, since) + val s = state.status() + assertEquals(s.getString("endpoint"), s.getString("registered")) + state.endpoint("https://push.example.net/2", "k", "a") + val moved = state.status() + assertEquals("https://push.example.net/1", moved.getString("registered")) + assertEquals("https://push.example.net/2", moved.getString("endpoint")) + } + + @Test fun `no distributor, or one that gives no keys, means fetching and not failing`() { + val state = PushState(FakePrefs()) + state.requested() + state.endpoint("https://push.example.net/1", null, null) + val s = state.status() + assertEquals(PushState.READY, s.getString("state")) + assertEquals("NO_KEYS", s.getString("reason")) + assertTrue(s.isNull("endpoint")) + state.remember(sub, account, secret, since) + assertEquals(PushState.PollTarget(sub, secret, since), state.pollTarget()) + } + + @Test fun `the fetch cursor only moves forward`() { + val state = PushState(FakePrefs()) + state.requested() + state.remember(sub, account, secret, since) + state.advance("2026-10-09T09:00:00+00:00") + assertEquals(since, state.pollTarget()!!.since) + state.advance("2026-10-09T11:00:00+00:00") + assertEquals("2026-10-09T11:00:00+00:00", state.pollTarget()!!.since) + } + + @Test fun `a line pushed then fetched is drawn once, and a conversation moving on is news`() { + val state = PushState(FakePrefs()) + assertTrue(state.firstSight(7, "2026-10-09T10:00:00+00:00")) + assertFalse(state.firstSight(7, "2026-10-09T10:00:00+00:00")) + assertTrue(state.firstSight(7, "2026-10-09T10:05:00+00:00")) + assertFalse(state.firstSight(7, "2026-10-09T10:01:00+00:00")) + } + + @Test fun `a row the hub forgot stops the fetch until the page registers again`() { + val state = PushState(FakePrefs()) + state.requested() + state.remember(sub, account, secret, since) + state.forgetSubscription() + assertNull(state.pollTarget()) + } + + @Test fun `only ids are remembered`() { + val state = PushState(FakePrefs()) + assertThrows(IllegalArgumentException::class.java) { state.remember("../x", account, secret, since) } + assertThrows(IllegalArgumentException::class.java) { state.remember(sub, "", secret, since) } + assertThrows(IllegalArgumentException::class.java) { state.remember(sub, account, "short", since) } + assertThrows(IllegalArgumentException::class.java) { state.remember(sub, account, secret, "yesterday") } + } +} |