aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-android/app/src')
-rw-r--r--packages/meshbay-android/app/src/main/AndroidManifest.xml30
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js27
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt92
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt6
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt2
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt91
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt65
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt29
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt76
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt41
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt126
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/BackupService.kt91
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt298
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt91
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt167
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt84
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt6
-rw-r--r--packages/meshbay-android/app/src/main/res/drawable/ic_notify.xml11
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt182
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt108
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") }
+ }
+}