aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-android/app/src/main')
-rw-r--r--packages/meshbay-android/app/src/main/AndroidManifest.xml12
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js16
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt69
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt3
-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
10 files changed, 831 insertions, 6 deletions
diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml
index 0c234aa..37c18c7 100644
--- a/packages/meshbay-android/app/src/main/AndroidManifest.xml
+++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml
@@ -13,6 +13,14 @@
<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
@@ -41,6 +49,10 @@
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
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 82e556d..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
@@ -197,6 +197,22 @@
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 dad8462..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
@@ -35,6 +35,8 @@ 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
@@ -56,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 }
@@ -63,6 +67,7 @@ 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
@@ -91,19 +96,24 @@ 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() } },
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)) }))
+ 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) {
@@ -141,6 +151,9 @@ class MainActivity : Activity() {
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.
@@ -258,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,
@@ -272,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())
@@ -335,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 5f202ba..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
@@ -4,6 +4,7 @@ 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
@@ -28,6 +29,7 @@ class Channels(
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
@@ -56,6 +58,7 @@ class Channels(
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/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