aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-android/README.md8
-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
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt182
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js264
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js316
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js25
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/settings-page.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css5
-rw-r--r--packages/meshbay-hub/tests/harness/photo_sync_probe.py182
-rw-r--r--packages/meshbay-hub/tests/test_android_cast.py7
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py13
-rw-r--r--packages/meshbay-hub/tests/test_first_load_is_lean.py2
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py2
-rw-r--r--packages/meshbay-hub/tests/test_photo_sync.py307
-rw-r--r--packages/meshbay-hub/tests/test_photo_sync_ui.py55
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py2
36 files changed, 2676 insertions, 13 deletions
diff --git a/packages/meshbay-android/README.md b/packages/meshbay-android/README.md
index 69977ec..adb7e66 100644
--- a/packages/meshbay-android/README.md
+++ b/packages/meshbay-android/README.md
@@ -62,6 +62,14 @@ connector could decrypt, and the fetch slows to a four-hour net. The page turns
it on in Settings and registers the phone with the hub; muting and "disable
all" are decided on the hub, which then creates nothing (§11.3).
+Photo backup (`photos/`, §9.12): MediaStore is listed natively, a ledger of
+what was sent is kept per account, group and folder, and each photo's bytes are
+handed to the page at `/photosync/<token>` on the packaged origin — never a
+URI. The page decides when a run is due and sends; a `dataSync` foreground
+service (`BackupService`) keeps it going with the screen off. No
+`ACCESS_MEDIA_LOCATION`, so the platform redacts a photo's location from what
+is read.
+
Not built yet: phone-specific behaviour (back button, network handover,
keeping a download alive with the screen off), updates through a store.
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
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-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index e2d5742..41a608f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -904,6 +904,30 @@ function App() {
pullOnce(user.userId).catch(() => {});
}, [user, groups.length]);
+ // Photo backup (docs/MESHBAY_DESIGN.md §9.12), on the Android application
+ // only: loaded and started once signed in, stopped on sign-out. A run reads
+ // the live user and bundle key when it starts, never ones captured here.
+ const syncAccount = user ? user.username : null;
+ useEffect(() => {
+ if (!syncAccount || !platform.photoSync.available) return undefined;
+ let stop = null;
+ let gone = false;
+ import('./photo-sync.js').then((m) => {
+ if (gone) return;
+ m.startPhotoSync({
+ hub: HUB,
+ getUser: () => userRef.current,
+ getBundleKey: async () => {
+ const key = session.bundleKey || await _loadBundleKey();
+ if (key) session.bundleKey = key;
+ return key;
+ },
+ });
+ stop = m.stopPhotoSync;
+ }).catch((e) => console.warn('[MeshBay] photo backup did not load:', e.message));
+ return () => { gone = true; if (stop) stop(); };
+ }, [syncAccount]);
+
// Saving the queue is a shell-level action because the queue is: the player
// bar outlives every page, and the account it belongs to is here.
const [saveQueue, setSaveQueue] = useState(null);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 07d2622..f3c3b79 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -1282,4 +1282,49 @@ export default {
'native.node_account_confirm': 'Der Node auf diesem Computer ist für {current} eingerichtet. Stattdessen für {next} einrichten? Er stellt dann die Gruppen, die er für {current} hostet, nicht mehr bereit.',
'native.browser_access_confirm': 'Die Anmeldung über einen Browser erlauben? Die Anwendung legt Ihre Identität auf jedem genutzten Node ab, so versiegelt, dass nur Ihre Passphrase zusammen mit Ihrem Hub-Konto sie öffnen kann.',
'native.declined': 'Abgebrochen — nichts wurde geändert.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Foto-Sicherung",
+ 'photosync.intro': "Die mit diesem Telefon aufgenommenen Fotos einmal täglich über WLAN in einen Ordner einer Ihrer Gruppen senden. Auf dem Telefon gelöschte Fotos bleiben in der Gruppe.",
+ 'photosync.setup': "Einrichten",
+ 'photosync.change': "Ändern",
+ 'photosync.turn_off': "Ausschalten",
+ 'photosync.now': "Jetzt sichern",
+ 'photosync.permission_needed': "MeshBay braucht Zugriff auf die Fotos dieses Telefons.",
+ 'photosync.permission_allow': "Zugriff erlauben",
+ 'photosync.permission_partial': "Der Zugriff ist auf die ausgewählten Fotos beschränkt: Neue Fotos werden erst gesichert, wenn Sie den Zugriff auf alle erlauben.",
+ 'photosync.group': "Gruppe",
+ 'photosync.choose_group': "Gruppe wählen",
+ 'photosync.checking_group': "Ordner der Gruppe werden gelesen…",
+ 'photosync.group_offline': "Gerade ist kein Node online, der diese Gruppe hostet. Versuchen Sie es erneut, sobald einer online ist.",
+ 'photosync.no_folder': "Diese Gruppe hat keinen Ordner, in den Sie hochladen können und den ihr Fotos-Tab anzeigt.",
+ 'photosync.folder': "Ordner",
+ 'photosync.albums': "Alben auf diesem Telefon",
+ 'photosync.album_camera': "Kamera",
+ 'photosync.album_count': "{n} Fotos · {size}",
+ 'photosync.scope_existing': "Die Fotos, die schon auf diesem Telefon sind, dann neue",
+ 'photosync.scope_new': "Nur Fotos, die ab jetzt aufgenommen werden",
+ 'photosync.start': "Sicherung starten",
+ 'photosync.confirm': "Ihre Fotos werden an {group} (gehostet von @{owner}, {members} Mitglieder) in den Ordner {folder} gesendet. Alle Mitglieder dieser Gruppe können sie sehen und herunterladen.\n\n{count} Fotos ({size}) werden gesendet, danach neue einmal täglich.\n\nSie können später löschen, was Sie gesendet haben, aber keine Kopien, die andere bereits heruntergeladen haben.",
+ 'photosync.confirm_new': "Ihre Fotos werden an {group} (gehostet von @{owner}, {members} Mitglieder) in den Ordner {folder} gesendet. Alle Mitglieder dieser Gruppe können sie sehen und herunterladen.\n\nNeue Fotos werden einmal täglich gesendet.\n\nSie können später löschen, was Sie gesendet haben, aber keine Kopien, die andere bereits heruntergeladen haben.",
+ 'photosync.metered_confirm': "Sie sind mit mobilen Daten verbunden — {count} Fotos, {size}. Trotzdem senden?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Eingerichtet für @{account}. Melden Sie sich als @{account} an, um sie fortzusetzen, oder richten Sie sie hier neu ein.",
+ 'photosync.sent_total': "Gesendete Fotos: {n}",
+ 'photosync.last_run': "Letzte Sicherung: {when}",
+ 'photosync.never': "Noch nicht gesichert",
+ 'photosync.state_waiting_wifi': "Wartet auf WLAN",
+ 'photosync.state_connecting': "Verbindung zur Gruppe…",
+ 'photosync.state_sending': "Sende {done} / {total}",
+ 'photosync.state_offline': "Kein Node der Gruppe ist online; später neuer Versuch",
+ 'photosync.state_interrupted': "Unterbrochen — wird bei nächster Gelegenheit fortgesetzt",
+ 'photosync.state_up_to_date': "Aktuell",
+ 'photosync.notif_progress': "Fotos werden gesichert — {done} / {total}",
+ 'photosync.fail_disk_full': "Foto-Sicherung angehalten: Die Festplatte des Nodes ist voll.",
+ 'photosync.fail_read_only': "Foto-Sicherung angehalten: Der Ordner nimmt keine Uploads mehr an.",
+ 'photosync.fail_no_folder': "Foto-Sicherung angehalten: Der Ordner existiert in der Gruppe nicht mehr.",
+ 'photosync.fail_unavailable': "Foto-Sicherung angehalten: Das Laufwerk des Ordners ist nicht mit dem Node verbunden.",
+ 'photosync.fail_not_member': "Foto-Sicherung angehalten: Sie sind kein Mitglied dieser Gruppe mehr.",
+ 'photosync.fail_permission': "Foto-Sicherung angehalten: MeshBay hat keinen Zugriff mehr auf die Fotos.",
+ 'photosync.fail_other': "Foto-Sicherung angehalten.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index a25fb2e..013a2a5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -1263,4 +1263,49 @@ export default {
'native.node_account_confirm': 'The node on this computer is set up for {current}. Set it up for {next} instead? It will stop serving the groups it hosts for {current}.',
'native.browser_access_confirm': 'Allow signing in from a browser? The application will leave your identity on every node you use, sealed so that only your passphrase together with your hub account can open it.',
'native.declined': 'Cancelled — nothing was changed.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Photo backup",
+ 'photosync.intro': "Send the photos taken on this phone to a folder of one of your groups, once a day, on Wi-Fi. Photos deleted from the phone stay in the group.",
+ 'photosync.setup': "Set up",
+ 'photosync.change': "Change",
+ 'photosync.turn_off': "Turn off",
+ 'photosync.now': "Back up now",
+ 'photosync.permission_needed': "MeshBay needs access to this phone's photos.",
+ 'photosync.permission_allow': "Allow access",
+ 'photosync.permission_partial': "Access is limited to the photos you selected: new photos will not be backed up until you allow access to all of them.",
+ 'photosync.group': "Group",
+ 'photosync.choose_group': "Choose a group",
+ 'photosync.checking_group': "Looking at the group's folders…",
+ 'photosync.group_offline': "No node hosting this group is online right now. Try again when it is.",
+ 'photosync.no_folder': "This group has no folder you can add to that its Photos tab shows.",
+ 'photosync.folder': "Folder",
+ 'photosync.albums': "Albums on this phone",
+ 'photosync.album_camera': "Camera",
+ 'photosync.album_count': "{n} photos · {size}",
+ 'photosync.scope_existing': "The photos already on this phone, then new ones",
+ 'photosync.scope_new': "Only photos taken from now on",
+ 'photosync.start': "Start backup",
+ 'photosync.confirm': "Your photos will be sent to {group} (hosted by @{owner}, {members} members) into {folder}. Every member of this group will be able to see and download them.\n\n{count} photos ({size}) will be sent, then new ones once a day.\n\nYou can delete what you sent later, but not copies others have already downloaded.",
+ 'photosync.confirm_new': "Your photos will be sent to {group} (hosted by @{owner}, {members} members) into {folder}. Every member of this group will be able to see and download them.\n\nNew photos will be sent once a day.\n\nYou can delete what you sent later, but not copies others have already downloaded.",
+ 'photosync.metered_confirm': "You are on mobile data — {count} photos, {size}. Send them anyway?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Set up for @{account}. Sign in as @{account} to continue it, or set it up again here.",
+ 'photosync.sent_total': "Photos sent: {n}",
+ 'photosync.last_run': "Last backup: {when}",
+ 'photosync.never': "Not backed up yet",
+ 'photosync.state_waiting_wifi': "Waiting for Wi-Fi",
+ 'photosync.state_connecting': "Connecting to the group…",
+ 'photosync.state_sending': "Sending {done} / {total}",
+ 'photosync.state_offline': "No node hosting the group is online; it will try again later",
+ 'photosync.state_interrupted': "Interrupted — it will carry on at the next chance",
+ 'photosync.state_up_to_date': "Up to date",
+ 'photosync.notif_progress': "Backing up photos — {done} / {total}",
+ 'photosync.fail_disk_full': "Photo backup stopped: the node's disk is full.",
+ 'photosync.fail_read_only': "Photo backup stopped: the folder no longer accepts uploads.",
+ 'photosync.fail_no_folder': "Photo backup stopped: the folder no longer exists in the group.",
+ 'photosync.fail_unavailable': "Photo backup stopped: the folder's drive is not connected to the node.",
+ 'photosync.fail_not_member': "Photo backup stopped: you are no longer a member of this group.",
+ 'photosync.fail_permission': "Photo backup stopped: MeshBay no longer has access to the photos.",
+ 'photosync.fail_other': "Photo backup stopped.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 0731b76..284f454 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -1276,4 +1276,49 @@ export default {
'native.node_account_confirm': 'El node de este ordenador está configurado para {current}. ¿Configurarlo para {next} en su lugar? Dejará de servir los grupos que aloja para {current}.',
'native.browser_access_confirm': '¿Permitir el acceso desde un navegador? La aplicación dejará su identidad en cada node que use, sellada de modo que solo su frase de contraseña, junto con su cuenta del hub, pueda abrirla.',
'native.declined': 'Cancelado: no se ha cambiado nada.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Copia de seguridad de fotos",
+ 'photosync.intro': "Envía las fotos tomadas con este teléfono a una carpeta de uno de tus grupos, una vez al día, por Wi-Fi. Las fotos borradas del teléfono se quedan en el grupo.",
+ 'photosync.setup': "Configurar",
+ 'photosync.change': "Cambiar",
+ 'photosync.turn_off': "Desactivar",
+ 'photosync.now': "Hacer copia ahora",
+ 'photosync.permission_needed': "MeshBay necesita acceso a las fotos de este teléfono.",
+ 'photosync.permission_allow': "Permitir acceso",
+ 'photosync.permission_partial': "El acceso está limitado a las fotos que seleccionaste: las fotos nuevas no se copiarán hasta que permitas el acceso a todas.",
+ 'photosync.group': "Grupo",
+ 'photosync.choose_group': "Elegir un grupo",
+ 'photosync.checking_group': "Leyendo las carpetas del grupo…",
+ 'photosync.group_offline': "Ningún node que aloje este grupo está en línea ahora. Vuelve a intentarlo cuando lo esté.",
+ 'photosync.no_folder': "Este grupo no tiene ninguna carpeta en la que puedas subir archivos y que muestre su pestaña Fotos.",
+ 'photosync.folder': "Carpeta",
+ 'photosync.albums': "Álbumes de este teléfono",
+ 'photosync.album_camera': "Cámara",
+ 'photosync.album_count': "{n} fotos · {size}",
+ 'photosync.scope_existing': "Las fotos que ya están en este teléfono y luego las nuevas",
+ 'photosync.scope_new': "Solo las fotos tomadas a partir de ahora",
+ 'photosync.start': "Iniciar la copia",
+ 'photosync.confirm': "Tus fotos se enviarán a {group} (alojado por @{owner}, {members} miembros), a la carpeta {folder}. Todos los miembros de este grupo podrán verlas y descargarlas.\n\nSe enviarán {count} fotos ({size}) y después las nuevas una vez al día.\n\nPodrás borrar lo que enviaste, pero no las copias que otros ya hayan descargado.",
+ 'photosync.confirm_new': "Tus fotos se enviarán a {group} (alojado por @{owner}, {members} miembros), a la carpeta {folder}. Todos los miembros de este grupo podrán verlas y descargarlas.\n\nLas fotos nuevas se enviarán una vez al día.\n\nPodrás borrar lo que enviaste, pero no las copias que otros ya hayan descargado.",
+ 'photosync.metered_confirm': "Estás usando datos móviles — {count} fotos, {size}. ¿Enviarlas de todos modos?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Configurada para @{account}. Inicia sesión como @{account} para continuarla, o vuelve a configurarla aquí.",
+ 'photosync.sent_total': "Fotos enviadas: {n}",
+ 'photosync.last_run': "Última copia: {when}",
+ 'photosync.never': "Aún sin copia",
+ 'photosync.state_waiting_wifi': "Esperando Wi-Fi",
+ 'photosync.state_connecting': "Conectando con el grupo…",
+ 'photosync.state_sending': "Enviando {done} / {total}",
+ 'photosync.state_offline': "Ningún node del grupo está en línea; se volverá a intentar más tarde",
+ 'photosync.state_interrupted': "Interrumpida — continuará en la próxima ocasión",
+ 'photosync.state_up_to_date': "Al día",
+ 'photosync.notif_progress': "Copia de fotos — {done} / {total}",
+ 'photosync.fail_disk_full': "La copia de fotos se detuvo: el disco del node está lleno.",
+ 'photosync.fail_read_only': "La copia de fotos se detuvo: la carpeta ya no acepta subidas.",
+ 'photosync.fail_no_folder': "La copia de fotos se detuvo: la carpeta ya no existe en el grupo.",
+ 'photosync.fail_unavailable': "La copia de fotos se detuvo: el disco de la carpeta no está conectado al node.",
+ 'photosync.fail_not_member': "La copia de fotos se detuvo: ya no eres miembro de este grupo.",
+ 'photosync.fail_permission': "La copia de fotos se detuvo: MeshBay ya no tiene acceso a las fotos.",
+ 'photosync.fail_other': "La copia de fotos se detuvo.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index e67f50d..4554ffb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -1291,4 +1291,49 @@ export default {
'native.node_account_confirm': 'Le node de cet ordinateur est configuré pour {current}. Le configurer pour {next} à la place ? Il cessera de servir les groupes qu\'il héberge pour {current}.',
'native.browser_access_confirm': 'Autoriser la connexion depuis un navigateur ? L\'application déposera votre identité sur chaque node que vous utilisez, scellée de sorte que seule votre phrase secrète, avec votre compte sur le hub, puisse l\'ouvrir.',
'native.declined': 'Annulé — rien n\'a été modifié.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Sauvegarde des photos",
+ 'photosync.intro': "Envoyer les photos prises avec ce téléphone dans un dossier d'un de vos groupes, une fois par jour, en Wi-Fi. Les photos supprimées du téléphone restent dans le groupe.",
+ 'photosync.setup': "Configurer",
+ 'photosync.change': "Modifier",
+ 'photosync.turn_off': "Désactiver",
+ 'photosync.now': "Sauvegarder maintenant",
+ 'photosync.permission_needed': "MeshBay a besoin d'accéder aux photos de ce téléphone.",
+ 'photosync.permission_allow': "Autoriser l'accès",
+ 'photosync.permission_partial': "L'accès est limité aux photos que vous avez sélectionnées : les nouvelles photos ne seront pas sauvegardées tant que vous n'autorisez pas l'accès à toutes.",
+ 'photosync.group': "Groupe",
+ 'photosync.choose_group': "Choisir un groupe",
+ 'photosync.checking_group': "Lecture des dossiers du groupe…",
+ 'photosync.group_offline': "Aucun node hébergeant ce groupe n'est en ligne pour le moment. Réessayez quand il le sera.",
+ 'photosync.no_folder': "Ce groupe n'a aucun dossier où vous pouvez déposer des fichiers et que son onglet Photos affiche.",
+ 'photosync.folder': "Dossier",
+ 'photosync.albums': "Albums de ce téléphone",
+ 'photosync.album_camera': "Appareil photo",
+ 'photosync.album_count': "{n} photos · {size}",
+ 'photosync.scope_existing': "Les photos déjà sur ce téléphone, puis les nouvelles",
+ 'photosync.scope_new': "Seulement les photos prises à partir de maintenant",
+ 'photosync.start': "Lancer la sauvegarde",
+ 'photosync.confirm': "Vos photos seront envoyées dans {group} (hébergé par @{owner}, {members} membres), dans le dossier {folder}. Tous les membres de ce groupe pourront les voir et les télécharger.\n\n{count} photos ({size}) seront envoyées, puis les nouvelles une fois par jour.\n\nVous pourrez supprimer ce que vous avez envoyé, mais pas les copies que d'autres auront déjà téléchargées.",
+ 'photosync.confirm_new': "Vos photos seront envoyées dans {group} (hébergé par @{owner}, {members} membres), dans le dossier {folder}. Tous les membres de ce groupe pourront les voir et les télécharger.\n\nLes nouvelles photos seront envoyées une fois par jour.\n\nVous pourrez supprimer ce que vous avez envoyé, mais pas les copies que d'autres auront déjà téléchargées.",
+ 'photosync.metered_confirm': "Vous êtes en données mobiles — {count} photos, {size}. Les envoyer quand même ?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Configurée pour @{account}. Connectez-vous en tant que @{account} pour la poursuivre, ou configurez-la à nouveau ici.",
+ 'photosync.sent_total': "Photos envoyées : {n}",
+ 'photosync.last_run': "Dernière sauvegarde : {when}",
+ 'photosync.never': "Pas encore sauvegardé",
+ 'photosync.state_waiting_wifi': "En attente du Wi-Fi",
+ 'photosync.state_connecting': "Connexion au groupe…",
+ 'photosync.state_sending': "Envoi {done} / {total}",
+ 'photosync.state_offline': "Aucun node hébergeant le groupe n'est en ligne ; nouvel essai plus tard",
+ 'photosync.state_interrupted': "Interrompue — elle reprendra à la prochaine occasion",
+ 'photosync.state_up_to_date': "À jour",
+ 'photosync.notif_progress': "Sauvegarde des photos — {done} / {total}",
+ 'photosync.fail_disk_full': "Sauvegarde des photos arrêtée : le disque du node est plein.",
+ 'photosync.fail_read_only': "Sauvegarde des photos arrêtée : le dossier n'accepte plus les dépôts.",
+ 'photosync.fail_no_folder': "Sauvegarde des photos arrêtée : le dossier n'existe plus dans le groupe.",
+ 'photosync.fail_unavailable': "Sauvegarde des photos arrêtée : le disque du dossier n'est pas branché sur le node.",
+ 'photosync.fail_not_member': "Sauvegarde des photos arrêtée : vous n'êtes plus membre de ce groupe.",
+ 'photosync.fail_permission': "Sauvegarde des photos arrêtée : MeshBay n'a plus accès aux photos.",
+ 'photosync.fail_other': "Sauvegarde des photos arrêtée.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index d88b8d5..bc091e3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -1290,4 +1290,49 @@ export default {
'native.node_account_confirm': 'Il node di questo computer è configurato per {current}. Configurarlo invece per {next}? Smetterà di servire i gruppi che ospita per {current}.',
'native.browser_access_confirm': 'Consentire l\'accesso da un browser? L\'applicazione lascerà la tua identità su ogni node che usi, sigillata in modo che solo la tua passphrase, insieme al tuo account sull\'hub, possa aprirla.',
'native.declined': 'Annullato: non è stato modificato nulla.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Backup delle foto",
+ 'photosync.intro': "Invia le foto scattate con questo telefono in una cartella di uno dei tuoi gruppi, una volta al giorno, in Wi-Fi. Le foto eliminate dal telefono restano nel gruppo.",
+ 'photosync.setup': "Configura",
+ 'photosync.change': "Modifica",
+ 'photosync.turn_off': "Disattiva",
+ 'photosync.now': "Esegui ora il backup",
+ 'photosync.permission_needed': "MeshBay ha bisogno di accedere alle foto di questo telefono.",
+ 'photosync.permission_allow': "Consenti l'accesso",
+ 'photosync.permission_partial': "L'accesso è limitato alle foto che hai selezionato: le nuove foto non verranno salvate finché non consenti l'accesso a tutte.",
+ 'photosync.group': "Gruppo",
+ 'photosync.choose_group': "Scegli un gruppo",
+ 'photosync.checking_group': "Lettura delle cartelle del gruppo…",
+ 'photosync.group_offline': "Nessun node che ospita questo gruppo è online ora. Riprova quando lo sarà.",
+ 'photosync.no_folder': "Questo gruppo non ha nessuna cartella in cui puoi caricare file e che la sua scheda Foto mostri.",
+ 'photosync.folder': "Cartella",
+ 'photosync.albums': "Album su questo telefono",
+ 'photosync.album_camera': "Fotocamera",
+ 'photosync.album_count': "{n} foto · {size}",
+ 'photosync.scope_existing': "Le foto già su questo telefono, poi quelle nuove",
+ 'photosync.scope_new': "Solo le foto scattate da ora in poi",
+ 'photosync.start': "Avvia il backup",
+ 'photosync.confirm': "Le tue foto verranno inviate a {group} (ospitato da @{owner}, {members} membri), nella cartella {folder}. Tutti i membri di questo gruppo potranno vederle e scaricarle.\n\nVerranno inviate {count} foto ({size}), poi quelle nuove una volta al giorno.\n\nPotrai eliminare ciò che hai inviato, ma non le copie che altri hanno già scaricato.",
+ 'photosync.confirm_new': "Le tue foto verranno inviate a {group} (ospitato da @{owner}, {members} membri), nella cartella {folder}. Tutti i membri di questo gruppo potranno vederle e scaricarle.\n\nLe nuove foto verranno inviate una volta al giorno.\n\nPotrai eliminare ciò che hai inviato, ma non le copie che altri hanno già scaricato.",
+ 'photosync.metered_confirm': "Stai usando i dati mobili — {count} foto, {size}. Inviarle comunque?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Configurato per @{account}. Accedi come @{account} per continuarlo, oppure configuralo di nuovo qui.",
+ 'photosync.sent_total': "Foto inviate: {n}",
+ 'photosync.last_run': "Ultimo backup: {when}",
+ 'photosync.never': "Nessun backup ancora",
+ 'photosync.state_waiting_wifi': "In attesa del Wi-Fi",
+ 'photosync.state_connecting': "Connessione al gruppo…",
+ 'photosync.state_sending': "Invio {done} / {total}",
+ 'photosync.state_offline': "Nessun node del gruppo è online; nuovo tentativo più tardi",
+ 'photosync.state_interrupted': "Interrotto — riprenderà alla prossima occasione",
+ 'photosync.state_up_to_date': "Aggiornato",
+ 'photosync.notif_progress': "Backup delle foto — {done} / {total}",
+ 'photosync.fail_disk_full': "Backup delle foto interrotto: il disco del node è pieno.",
+ 'photosync.fail_read_only': "Backup delle foto interrotto: la cartella non accetta più caricamenti.",
+ 'photosync.fail_no_folder': "Backup delle foto interrotto: la cartella non esiste più nel gruppo.",
+ 'photosync.fail_unavailable': "Backup delle foto interrotto: il disco della cartella non è collegato al node.",
+ 'photosync.fail_not_member': "Backup delle foto interrotto: non sei più membro di questo gruppo.",
+ 'photosync.fail_permission': "Backup delle foto interrotto: MeshBay non ha più accesso alle foto.",
+ 'photosync.fail_other': "Backup delle foto interrotto.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index edf134d..0522f4c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -1274,4 +1274,49 @@ export default {
'native.node_account_confirm': 'このコンピューターの node は {current} 用に設定されています。代わりに {next} 用に設定しますか?{current} のためにホストしているグループは提供されなくなります。',
'native.browser_access_confirm': 'ブラウザーからのサインインを許可しますか?アプリは、利用中のすべての node にあなたの ID を残します。これは、パスフレーズと hub のアカウントの両方がそろった場合にのみ開けるよう封印されます。',
'native.declined': 'キャンセルしました。何も変更されていません。',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "写真のバックアップ",
+ 'photosync.intro': "この電話で撮った写真を、1 日 1 回 Wi-Fi で、グループのいずれかのフォルダーに送ります。電話から削除した写真はグループに残ります。",
+ 'photosync.setup': "設定する",
+ 'photosync.change': "変更",
+ 'photosync.turn_off': "オフにする",
+ 'photosync.now': "今すぐバックアップ",
+ 'photosync.permission_needed': "MeshBay がこの電話の写真にアクセスする必要があります。",
+ 'photosync.permission_allow': "アクセスを許可",
+ 'photosync.permission_partial': "アクセスは選択した写真に限られています。すべての写真へのアクセスを許可するまで、新しい写真はバックアップされません。",
+ 'photosync.group': "グループ",
+ 'photosync.choose_group': "グループを選択",
+ 'photosync.checking_group': "グループのフォルダーを確認しています…",
+ 'photosync.group_offline': "このグループをホストしている node は現在オンラインではありません。オンラインになってから再度お試しください。",
+ 'photosync.no_folder': "このグループには、アップロードでき、かつ写真タブに表示されるフォルダーがありません。",
+ 'photosync.folder': "フォルダー",
+ 'photosync.albums': "この電話のアルバム",
+ 'photosync.album_camera': "カメラ",
+ 'photosync.album_count': "{n} 枚 · {size}",
+ 'photosync.scope_existing': "この電話にすでにある写真と、これからの写真",
+ 'photosync.scope_new': "これから撮る写真のみ",
+ 'photosync.start': "バックアップを開始",
+ 'photosync.confirm': "写真は {group}(ホスト: @{owner}、メンバー {members} 人)のフォルダー {folder} に送られます。このグループのメンバー全員が写真を見てダウンロードできます。\n\n{count} 枚({size})を送信し、その後は新しい写真を 1 日 1 回送信します。\n\n送信したものは後で削除できますが、他の人がすでにダウンロードしたコピーは削除できません。",
+ 'photosync.confirm_new': "写真は {group}(ホスト: @{owner}、メンバー {members} 人)のフォルダー {folder} に送られます。このグループのメンバー全員が写真を見てダウンロードできます。\n\n新しい写真を 1 日 1 回送信します。\n\n送信したものは後で削除できますが、他の人がすでにダウンロードしたコピーは削除できません。",
+ 'photosync.metered_confirm': "モバイルデータ通信中です — {count} 枚、{size}。それでも送信しますか?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "@{account} 用に設定されています。続けるには @{account} でサインインするか、ここで設定し直してください。",
+ 'photosync.sent_total': "送信済みの写真: {n}",
+ 'photosync.last_run': "前回のバックアップ: {when}",
+ 'photosync.never': "まだバックアップしていません",
+ 'photosync.state_waiting_wifi': "Wi-Fi を待っています",
+ 'photosync.state_connecting': "グループに接続しています…",
+ 'photosync.state_sending': "送信中 {done} / {total}",
+ 'photosync.state_offline': "グループの node がオンラインではありません。後で再試行します",
+ 'photosync.state_interrupted': "中断しました — 次の機会に続行します",
+ 'photosync.state_up_to_date': "最新です",
+ 'photosync.notif_progress': "写真をバックアップ中 — {done} / {total}",
+ 'photosync.fail_disk_full': "写真のバックアップを停止しました: node のディスクがいっぱいです。",
+ 'photosync.fail_read_only': "写真のバックアップを停止しました: フォルダーがアップロードを受け付けなくなりました。",
+ 'photosync.fail_no_folder': "写真のバックアップを停止しました: フォルダーがグループに存在しなくなりました。",
+ 'photosync.fail_unavailable': "写真のバックアップを停止しました: フォルダーのドライブが node に接続されていません。",
+ 'photosync.fail_not_member': "写真のバックアップを停止しました: このグループのメンバーではなくなりました。",
+ 'photosync.fail_permission': "写真のバックアップを停止しました: MeshBay が写真にアクセスできなくなりました。",
+ 'photosync.fail_other': "写真のバックアップを停止しました。",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 0eaedbb..b258b04 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -1292,4 +1292,49 @@ export default {
'native.node_account_confirm': 'De node op deze computer is ingesteld voor {current}. In plaats daarvan instellen voor {next}? Hij stopt dan met het aanbieden van de groepen die hij voor {current} host.',
'native.browser_access_confirm': 'Aanmelden vanuit een browser toestaan? De toepassing laat je identiteit achter op elke node die je gebruikt, zo verzegeld dat alleen je wachtzin samen met je hub-account haar kan openen.',
'native.declined': 'Geannuleerd — er is niets gewijzigd.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Fotoback-up",
+ 'photosync.intro': "Stuur de foto's die met deze telefoon zijn gemaakt eenmaal per dag via wifi naar een map van een van je groepen. Foto's die van de telefoon worden verwijderd, blijven in de groep.",
+ 'photosync.setup': "Instellen",
+ 'photosync.change': "Wijzigen",
+ 'photosync.turn_off': "Uitschakelen",
+ 'photosync.now': "Nu back-uppen",
+ 'photosync.permission_needed': "MeshBay heeft toegang nodig tot de foto's op deze telefoon.",
+ 'photosync.permission_allow': "Toegang toestaan",
+ 'photosync.permission_partial': "De toegang is beperkt tot de foto's die je hebt geselecteerd: nieuwe foto's worden pas geback-upt als je toegang tot alle foto's toestaat.",
+ 'photosync.group': "Groep",
+ 'photosync.choose_group': "Kies een groep",
+ 'photosync.checking_group': "Mappen van de groep worden gelezen…",
+ 'photosync.group_offline': "Er is nu geen node online die deze groep host. Probeer het opnieuw als dat wel zo is.",
+ 'photosync.no_folder': "Deze groep heeft geen map waarin je kunt uploaden en die het tabblad Foto's toont.",
+ 'photosync.folder': "Map",
+ 'photosync.albums': "Albums op deze telefoon",
+ 'photosync.album_camera': "Camera",
+ 'photosync.album_count': "{n} foto's · {size}",
+ 'photosync.scope_existing': "De foto's die al op deze telefoon staan, daarna nieuwe",
+ 'photosync.scope_new': "Alleen foto's die vanaf nu worden gemaakt",
+ 'photosync.start': "Back-up starten",
+ 'photosync.confirm': "Je foto's worden naar {group} (gehost door @{owner}, {members} leden) gestuurd, in de map {folder}. Alle leden van deze groep kunnen ze zien en downloaden.\n\nEr worden {count} foto's ({size}) gestuurd, daarna eenmaal per dag de nieuwe.\n\nJe kunt wat je hebt gestuurd later verwijderen, maar niet de kopieën die anderen al hebben gedownload.",
+ 'photosync.confirm_new': "Je foto's worden naar {group} (gehost door @{owner}, {members} leden) gestuurd, in de map {folder}. Alle leden van deze groep kunnen ze zien en downloaden.\n\nNieuwe foto's worden eenmaal per dag gestuurd.\n\nJe kunt wat je hebt gestuurd later verwijderen, maar niet de kopieën die anderen al hebben gedownload.",
+ 'photosync.metered_confirm': "Je gebruikt mobiele data — {count} foto's, {size}. Toch versturen?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Ingesteld voor @{account}. Log in als @{account} om verder te gaan, of stel het hier opnieuw in.",
+ 'photosync.sent_total': "Verstuurde foto's: {n}",
+ 'photosync.last_run': "Laatste back-up: {when}",
+ 'photosync.never': "Nog geen back-up",
+ 'photosync.state_waiting_wifi': "Wacht op wifi",
+ 'photosync.state_connecting': "Verbinden met de groep…",
+ 'photosync.state_sending': "Versturen {done} / {total}",
+ 'photosync.state_offline': "Geen node van de groep is online; later nieuwe poging",
+ 'photosync.state_interrupted': "Onderbroken — gaat bij de volgende gelegenheid verder",
+ 'photosync.state_up_to_date': "Bijgewerkt",
+ 'photosync.notif_progress': "Foto's back-uppen — {done} / {total}",
+ 'photosync.fail_disk_full': "Fotoback-up gestopt: de schijf van de node is vol.",
+ 'photosync.fail_read_only': "Fotoback-up gestopt: de map accepteert geen uploads meer.",
+ 'photosync.fail_no_folder': "Fotoback-up gestopt: de map bestaat niet meer in de groep.",
+ 'photosync.fail_unavailable': "Fotoback-up gestopt: de schijf van de map is niet aangesloten op de node.",
+ 'photosync.fail_not_member': "Fotoback-up gestopt: je bent geen lid meer van deze groep.",
+ 'photosync.fail_permission': "Fotoback-up gestopt: MeshBay heeft geen toegang meer tot de foto's.",
+ 'photosync.fail_other': "Fotoback-up gestopt.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 6efebb8..a750c2f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -1318,4 +1318,49 @@ export default {
'native.node_account_confirm': 'Node na tym komputerze jest skonfigurowany dla {current}. Skonfigurować go zamiast tego dla {next}? Przestanie obsługiwać grupy, które hostuje dla {current}.',
'native.browser_access_confirm': 'Zezwolić na logowanie z przeglądarki? Aplikacja pozostawi Twoją tożsamość na każdym używanym node, zapieczętowaną tak, by otworzyć ją mogło tylko Twoje hasło wraz z kontem na hubie.',
'native.declined': 'Anulowano — nic nie zostało zmienione.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Kopia zdjęć",
+ 'photosync.intro': "Wysyłaj zdjęcia zrobione tym telefonem do folderu jednej z Twoich grup, raz dziennie, przez Wi-Fi. Zdjęcia usunięte z telefonu zostają w grupie.",
+ 'photosync.setup': "Skonfiguruj",
+ 'photosync.change': "Zmień",
+ 'photosync.turn_off': "Wyłącz",
+ 'photosync.now': "Utwórz kopię teraz",
+ 'photosync.permission_needed': "MeshBay potrzebuje dostępu do zdjęć na tym telefonie.",
+ 'photosync.permission_allow': "Zezwól na dostęp",
+ 'photosync.permission_partial': "Dostęp jest ograniczony do wybranych zdjęć: nowe zdjęcia nie będą kopiowane, dopóki nie zezwolisz na dostęp do wszystkich.",
+ 'photosync.group': "Grupa",
+ 'photosync.choose_group': "Wybierz grupę",
+ 'photosync.checking_group': "Odczytywanie folderów grupy…",
+ 'photosync.group_offline': "Żaden node hostujący tę grupę nie jest teraz dostępny. Spróbuj ponownie, gdy będzie.",
+ 'photosync.no_folder': "Ta grupa nie ma folderu, do którego możesz przesyłać pliki i który pokazuje jej karta Zdjęcia.",
+ 'photosync.folder': "Folder",
+ 'photosync.albums': "Albumy na tym telefonie",
+ 'photosync.album_camera': "Aparat",
+ 'photosync.album_count': "Zdjęcia: {n} · {size}",
+ 'photosync.scope_existing': "Zdjęcia, które już są na tym telefonie, a potem nowe",
+ 'photosync.scope_new': "Tylko zdjęcia zrobione od teraz",
+ 'photosync.start': "Rozpocznij kopię",
+ 'photosync.confirm': "Twoje zdjęcia zostaną wysłane do grupy {group} (hostowanej przez @{owner}, członkowie: {members}), do folderu {folder}. Wszyscy członkowie tej grupy będą mogli je oglądać i pobierać.\n\nZdjęcia do wysłania: {count} ({size}), a potem nowe raz dziennie.\n\nMożesz później usunąć to, co wysłałeś, ale nie kopie, które inni już pobrali.",
+ 'photosync.confirm_new': "Twoje zdjęcia zostaną wysłane do grupy {group} (hostowanej przez @{owner}, członkowie: {members}), do folderu {folder}. Wszyscy członkowie tej grupy będą mogli je oglądać i pobierać.\n\nNowe zdjęcia będą wysyłane raz dziennie.\n\nMożesz później usunąć to, co wysłałeś, ale nie kopie, które inni już pobrali.",
+ 'photosync.metered_confirm': "Korzystasz z danych komórkowych — zdjęcia: {count}, {size}. Wysłać mimo to?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Skonfigurowano dla @{account}. Zaloguj się jako @{account}, aby ją kontynuować, albo skonfiguruj ją tu ponownie.",
+ 'photosync.sent_total': "Wysłane zdjęcia: {n}",
+ 'photosync.last_run': "Ostatnia kopia: {when}",
+ 'photosync.never': "Jeszcze bez kopii",
+ 'photosync.state_waiting_wifi': "Oczekiwanie na Wi-Fi",
+ 'photosync.state_connecting': "Łączenie z grupą…",
+ 'photosync.state_sending': "Wysyłanie {done} / {total}",
+ 'photosync.state_offline': "Żaden node grupy nie jest dostępny; kolejna próba później",
+ 'photosync.state_interrupted': "Przerwano — będzie kontynuowana przy następnej okazji",
+ 'photosync.state_up_to_date': "Aktualna",
+ 'photosync.notif_progress': "Kopia zdjęć — {done} / {total}",
+ 'photosync.fail_disk_full': "Kopia zdjęć zatrzymana: dysk, na którym działa node, jest pełny.",
+ 'photosync.fail_read_only': "Kopia zdjęć zatrzymana: folder nie przyjmuje już plików.",
+ 'photosync.fail_no_folder': "Kopia zdjęć zatrzymana: folder już nie istnieje w grupie.",
+ 'photosync.fail_unavailable': "Kopia zdjęć zatrzymana: dysk z folderem nie jest podłączony do node.",
+ 'photosync.fail_not_member': "Kopia zdjęć zatrzymana: nie jesteś już członkiem tej grupy.",
+ 'photosync.fail_permission': "Kopia zdjęć zatrzymana: MeshBay nie ma już dostępu do zdjęć.",
+ 'photosync.fail_other': "Kopia zdjęć zatrzymana.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index df2d1f8..bd91cee 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -1277,4 +1277,49 @@ export default {
'native.node_account_confirm': 'O node deste computador está configurado para {current}. Configurá-lo para {next} em vez disso? Ele deixará de servir os grupos que hospeda para {current}.',
'native.browser_access_confirm': 'Permitir o acesso por um navegador? O aplicativo deixará sua identidade em cada node que você usa, selada de forma que apenas sua frase secreta, junto com sua conta no hub, possa abri-la.',
'native.declined': 'Cancelado — nada foi alterado.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Backup de fotos",
+ 'photosync.intro': "Envie as fotos tiradas com este celular para uma pasta de um dos seus grupos, uma vez por dia, pelo Wi-Fi. Fotos apagadas do celular continuam no grupo.",
+ 'photosync.setup': "Configurar",
+ 'photosync.change': "Alterar",
+ 'photosync.turn_off': "Desativar",
+ 'photosync.now': "Fazer backup agora",
+ 'photosync.permission_needed': "O MeshBay precisa de acesso às fotos deste celular.",
+ 'photosync.permission_allow': "Permitir acesso",
+ 'photosync.permission_partial': "O acesso está limitado às fotos que você selecionou: novas fotos não serão salvas até você permitir o acesso a todas.",
+ 'photosync.group': "Grupo",
+ 'photosync.choose_group': "Escolha um grupo",
+ 'photosync.checking_group': "Lendo as pastas do grupo…",
+ 'photosync.group_offline': "Nenhum node que hospeda este grupo está on-line agora. Tente de novo quando estiver.",
+ 'photosync.no_folder': "Este grupo não tem nenhuma pasta onde você possa enviar arquivos e que a aba Fotos mostre.",
+ 'photosync.folder': "Pasta",
+ 'photosync.albums': "Álbuns deste celular",
+ 'photosync.album_camera': "Câmera",
+ 'photosync.album_count': "{n} fotos · {size}",
+ 'photosync.scope_existing': "As fotos que já estão neste celular, depois as novas",
+ 'photosync.scope_new': "Somente as fotos tiradas a partir de agora",
+ 'photosync.start': "Iniciar backup",
+ 'photosync.confirm': "Suas fotos serão enviadas para {group} (hospedado por @{owner}, {members} membros), na pasta {folder}. Todos os membros deste grupo poderão vê-las e baixá-las.\n\nSerão enviadas {count} fotos ({size}) e, depois, as novas uma vez por dia.\n\nVocê poderá apagar o que enviou, mas não as cópias que outros já tiverem baixado.",
+ 'photosync.confirm_new': "Suas fotos serão enviadas para {group} (hospedado por @{owner}, {members} membros), na pasta {folder}. Todos os membros deste grupo poderão vê-las e baixá-las.\n\nAs novas fotos serão enviadas uma vez por dia.\n\nVocê poderá apagar o que enviou, mas não as cópias que outros já tiverem baixado.",
+ 'photosync.metered_confirm': "Você está usando dados móveis — {count} fotos, {size}. Enviar mesmo assim?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Configurado para @{account}. Entre como @{account} para continuar, ou configure de novo aqui.",
+ 'photosync.sent_total': "Fotos enviadas: {n}",
+ 'photosync.last_run': "Último backup: {when}",
+ 'photosync.never': "Ainda sem backup",
+ 'photosync.state_waiting_wifi': "Aguardando Wi-Fi",
+ 'photosync.state_connecting': "Conectando ao grupo…",
+ 'photosync.state_sending': "Enviando {done} / {total}",
+ 'photosync.state_offline': "Nenhum node do grupo está on-line; nova tentativa mais tarde",
+ 'photosync.state_interrupted': "Interrompido — continuará na próxima oportunidade",
+ 'photosync.state_up_to_date': "Atualizado",
+ 'photosync.notif_progress': "Backup de fotos — {done} / {total}",
+ 'photosync.fail_disk_full': "Backup de fotos parado: o disco do node está cheio.",
+ 'photosync.fail_read_only': "Backup de fotos parado: a pasta não aceita mais envios.",
+ 'photosync.fail_no_folder': "Backup de fotos parado: a pasta não existe mais no grupo.",
+ 'photosync.fail_unavailable': "Backup de fotos parado: o disco da pasta não está conectado ao node.",
+ 'photosync.fail_not_member': "Backup de fotos parado: você não é mais membro deste grupo.",
+ 'photosync.fail_permission': "Backup de fotos parado: o MeshBay não tem mais acesso às fotos.",
+ 'photosync.fail_other': "Backup de fotos parado.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 709eb65..d7b0aeb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -1263,4 +1263,49 @@ export default {
'native.node_account_confirm': '这台电脑上的 node 已为 {current} 设置。改为为 {next} 设置吗?它将不再为 {current} 提供其托管的群组。',
'native.browser_access_confirm': '允许从浏览器登录吗?应用会在您使用的每个 node 上留下您的身份,并加以封存,只有您的密码短语配合您的 hub 账户才能打开。',
'native.declined': '已取消,未做任何更改。',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "照片备份",
+ 'photosync.intro': "每天一次,通过 Wi-Fi 将这部手机拍摄的照片发送到您某个群组的文件夹。从手机删除的照片会保留在群组中。",
+ 'photosync.setup': "设置",
+ 'photosync.change': "更改",
+ 'photosync.turn_off': "关闭",
+ 'photosync.now': "立即备份",
+ 'photosync.permission_needed': "MeshBay 需要访问这部手机上的照片。",
+ 'photosync.permission_allow': "允许访问",
+ 'photosync.permission_partial': "访问权限仅限于您选择的照片:在您允许访问全部照片之前,新照片不会被备份。",
+ 'photosync.group': "群组",
+ 'photosync.choose_group': "选择群组",
+ 'photosync.checking_group': "正在读取群组的文件夹…",
+ 'photosync.group_offline': "目前没有托管此群组的 node 在线。请在其上线后重试。",
+ 'photosync.no_folder': "此群组没有您可以上传、且其照片标签页会显示的文件夹。",
+ 'photosync.folder': "文件夹",
+ 'photosync.albums': "这部手机上的相册",
+ 'photosync.album_camera': "相机",
+ 'photosync.album_count': "{n} 张照片 · {size}",
+ 'photosync.scope_existing': "手机上已有的照片,然后是新照片",
+ 'photosync.scope_new': "仅从现在起拍摄的照片",
+ 'photosync.start': "开始备份",
+ 'photosync.confirm': "您的照片将发送到 {group}(由 @{owner} 托管,{members} 位成员)的文件夹 {folder}。此群组的所有成员都可以查看和下载这些照片。\n\n将发送 {count} 张照片({size}),之后每天发送一次新照片。\n\n您之后可以删除已发送的内容,但无法删除他人已下载的副本。",
+ 'photosync.confirm_new': "您的照片将发送到 {group}(由 @{owner} 托管,{members} 位成员)的文件夹 {folder}。此群组的所有成员都可以查看和下载这些照片。\n\n新照片将每天发送一次。\n\n您之后可以删除已发送的内容,但无法删除他人已下载的副本。",
+ 'photosync.metered_confirm': "您正在使用移动数据 — {count} 张照片,{size}。仍要发送吗?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "已为 @{account} 设置。请以 @{account} 登录以继续,或在此重新设置。",
+ 'photosync.sent_total': "已发送照片:{n}",
+ 'photosync.last_run': "上次备份:{when}",
+ 'photosync.never': "尚未备份",
+ 'photosync.state_waiting_wifi': "正在等待 Wi-Fi",
+ 'photosync.state_connecting': "正在连接群组…",
+ 'photosync.state_sending': "正在发送 {done} / {total}",
+ 'photosync.state_offline': "群组的 node 均不在线;稍后重试",
+ 'photosync.state_interrupted': "已中断 — 将在下次有机会时继续",
+ 'photosync.state_up_to_date': "已是最新",
+ 'photosync.notif_progress': "正在备份照片 — {done} / {total}",
+ 'photosync.fail_disk_full': "照片备份已停止:node 的磁盘已满。",
+ 'photosync.fail_read_only': "照片备份已停止:该文件夹不再接受上传。",
+ 'photosync.fail_no_folder': "照片备份已停止:该文件夹已不在群组中。",
+ 'photosync.fail_unavailable': "照片备份已停止:该文件夹所在的磁盘未连接到 node。",
+ 'photosync.fail_not_member': "照片备份已停止:您已不再是此群组的成员。",
+ 'photosync.fail_permission': "照片备份已停止:MeshBay 已无法访问照片。",
+ 'photosync.fail_other': "照片备份已停止。",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js
new file mode 100644
index 0000000..1ec68d3
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js
@@ -0,0 +1,264 @@
+// The "Photo backup" section of Settings, on the Android application only
+// (docs/MESHBAY_DESIGN.md §9.12). Loaded on demand: no other client has it.
+
+import { html, useState, useEffect, useCallback } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import * as platform from './platform.js';
+import { hubFetch } from './hub-client.js';
+import { formatSize } from './file-utils.js';
+import { ask } from './ask.js';
+import { subscribe, runPhotoSync, probeGroup } from './photo-sync.js';
+
+function stateLine(st, status) {
+ switch (st.phase) {
+ case 'waiting_wifi': return t('photosync.state_waiting_wifi');
+ case 'connecting': return t('photosync.state_connecting');
+ case 'sending': return t('photosync.state_sending', { done: st.done, total: st.total });
+ case 'offline': return t('photosync.state_offline');
+ case 'interrupted': return t('photosync.state_interrupted');
+ case 'up_to_date': return t('photosync.state_up_to_date');
+ default: return status && status.lastCompleted ? t('photosync.state_up_to_date') : '';
+ }
+}
+
+const FAILURES = {
+ disk_full: 'photosync.fail_disk_full',
+ root_read_only: 'photosync.fail_read_only',
+ no_writable_root: 'photosync.fail_read_only',
+ no_such_root: 'photosync.fail_no_folder',
+ no_such_directory: 'photosync.fail_no_folder',
+ root_unavailable: 'photosync.fail_unavailable',
+ not_a_member: 'photosync.fail_not_member',
+ permission: 'photosync.fail_permission',
+};
+
+export function PhotoSyncSection({ user, groups }) {
+ const [status, setStatus] = useState(null);
+ const [run, setRun] = useState({ phase: 'idle' });
+ const [editing, setEditing] = useState(false);
+ const [error, setError] = useState('');
+
+ const refresh = useCallback(() => platform.photoSync.status().then(setStatus).catch(() => {}), []);
+ useEffect(() => { refresh(); }, [refresh]);
+ // A run that ends changes what the phone says (sent, last backup, a
+ // failure); read it again then rather than on a timer.
+ useEffect(() => subscribe((st) => {
+ setRun(st);
+ if (st.phase !== 'sending' && st.phase !== 'connecting') refresh();
+ }), [refresh]);
+
+ if (!status) return null;
+ const cfg = status.config;
+
+ const backUpNow = async () => {
+ setError('');
+ try {
+ if (!status.unmetered) {
+ const est = await platform.photoSync.estimate(cfg);
+ if (est.count === 0) { await runPhotoSync({ force: true, metered: true }); return; }
+ const yes = await ask(t('photosync.metered_confirm', {
+ count: est.count, size: formatSize(est.bytes) }));
+ if (!yes) return;
+ await runPhotoSync({ force: true, metered: true });
+ } else {
+ await runPhotoSync({ force: true });
+ }
+ } catch (e) { setError(e.message); }
+ };
+
+ const turnOff = async () => {
+ setError('');
+ try { setStatus(await platform.photoSync.configure(null)); } catch (e) { setError(e.message); }
+ };
+
+ return html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('photosync.heading')}</h3>
+ ${!cfg && !editing && html`
+ <p class="settings-hint">${t('photosync.intro')}</p>
+ <button class="admin-btn" onClick=${() => setEditing(true)}>${t('photosync.setup')}</button>
+ `}
+ ${cfg && !editing && html`
+ <div class="settings-row">
+ <span class="settings-label settings-name">
+ ${t('photosync.summary', { group: cfg.groupName, owner: cfg.owner, folder: cfg.folder })}
+ </span>
+ </div>
+ ${cfg.account !== user.username && html`
+ <p class="settings-hint">${t('photosync.other_account', { account: cfg.account })}</p>`}
+ <p class="settings-hint">
+ ${t('photosync.sent_total', { n: status.sent })}
+ ${' · '}
+ ${status.lastCompleted
+ ? t('photosync.last_run', { when: new Date(status.lastCompleted).toLocaleString() })
+ : t('photosync.never')}
+ </p>
+ ${status.permission === 'partial' && html`
+ <p class="settings-hint">${t('photosync.permission_partial')}</p>`}
+ ${status.failure
+ ? html`<p class="error-msg">${t(FAILURES[status.failure] || 'photosync.fail_other')}</p>`
+ : stateLine(run, status) && html`<p class="settings-hint">${stateLine(run, status)}</p>`}
+ <div class="settings-row" style="gap:8px; justify-content:flex-start; flex-wrap:wrap">
+ <button class="admin-btn" disabled=${run.phase === 'sending' || run.phase === 'connecting'}
+ onClick=${backUpNow}>${t('photosync.now')}</button>
+ <button class="btn-secondary" onClick=${() => setEditing(true)}>${t('photosync.change')}</button>
+ <button class="btn-secondary" onClick=${turnOff}>${t('photosync.turn_off')}</button>
+ </div>
+ `}
+ ${editing && html`
+ <${Setup} user=${user} groups=${groups} status=${status}
+ onPermission=${setStatus}
+ onDone=${(next) => { setEditing(false); if (next) { setStatus(next); runPhotoSync(); } }} />`}
+ ${error && html`<p class="error-msg">${error}</p>`}
+ </div>
+ `;
+}
+
+function Setup({ user, groups, status, onPermission, onDone }) {
+ const cfg = status.config && status.config.account === user.username ? status.config : null;
+ const [groupId, setGroupId] = useState(cfg ? cfg.groupId : '');
+ const [folders, setFolders] = useState(null); // null while unknown
+ const [probe, setProbe] = useState(''); // '', 'checking', 'offline'
+ const [folder, setFolder] = useState(cfg ? cfg.folder : '');
+ const [albums, setAlbums] = useState(null);
+ const [chosen, setChosen] = useState(() => new Set(cfg ? cfg.albums : []));
+ const [existing, setExisting] = useState(cfg ? cfg.includeExisting : true);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ const permitted = status.permission !== 'denied';
+
+ useEffect(() => {
+ if (!permitted) return;
+ platform.photoSync.albums().then((list) => {
+ setAlbums(list);
+ // The camera, and only the camera, unless the person chose before:
+ // screenshots and saved images are where photos nobody meant to share live.
+ if (!cfg) setChosen(new Set(list.filter((a) => a.camera).map((a) => a.id)));
+ }).catch((e) => setError(e.message));
+ }, [permitted]);
+
+ useEffect(() => {
+ setFolders(null);
+ if (!groupId) return;
+ let gone = false;
+ setProbe('checking');
+ probeGroup(groupId).then((r) => {
+ if (gone) return;
+ setProbe('');
+ setFolders(r.folders);
+ setFolder((f) => (r.folders.includes(f) ? f : (r.folders[0] || '')));
+ }).catch(() => { if (!gone) setProbe('offline'); });
+ return () => { gone = true; };
+ }, [groupId]);
+
+ const allow = async () => {
+ setError('');
+ try { onPermission(await platform.photoSync.permit()); } catch (e) { setError(e.message); }
+ };
+
+ const toggle = (id) => setChosen((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id); else next.add(id);
+ return next;
+ });
+
+ const start = async () => {
+ const group = groups.find((g) => g.id === groupId);
+ if (!group || !folder || !chosen.size) return;
+ setBusy(true);
+ setError('');
+ try {
+ const settings = {
+ account: user.username, groupId, groupName: group.name,
+ owner: group.owner_username || '', folder, albums: [...chosen], includeExisting: existing,
+ };
+ // Asked when the photos would go somewhere new, or from somewhere new in
+ // time — never for a change of albums alone, which the person just made.
+ const same = cfg && cfg.groupId === groupId && cfg.folder === folder
+ && cfg.includeExisting === existing;
+ if (!same) {
+ const [est, members] = await Promise.all([
+ platform.photoSync.estimate(settings),
+ hubFetch(`/v1/groups/${groupId}/members`, { token: user.token })
+ .then((r) => (r.members || []).length).catch(() => null),
+ ]);
+ const params = {
+ group: group.name, owner: group.owner_username || '', folder,
+ members: members == null ? '?' : members,
+ count: est.count, size: formatSize(est.bytes),
+ };
+ const yes = await ask(t(existing && est.count > 0 ? 'photosync.confirm'
+ : 'photosync.confirm_new', params));
+ if (!yes) { setBusy(false); return; }
+ }
+ onDone(await platform.photoSync.configure(settings));
+ } catch (e) {
+ setError(e.message);
+ setBusy(false);
+ }
+ };
+
+ return html`
+ <div>
+ ${!permitted && html`
+ <p class="settings-hint">${t('photosync.permission_needed')}</p>
+ <button class="admin-btn" onClick=${allow}>${t('photosync.permission_allow')}</button>
+ `}
+ ${status.permission === 'partial' && html`
+ <p class="settings-hint">${t('photosync.permission_partial')}</p>
+ <button class="btn-secondary" onClick=${allow}>${t('photosync.permission_allow')}</button>
+ `}
+ ${permitted && html`
+ <div class="settings-row">
+ <span class="settings-label">${t('photosync.group')}</span>
+ <select class="settings-select" value=${groupId}
+ onChange=${(e) => setGroupId(e.target.value)}>
+ <option value="">${t('photosync.choose_group')}</option>
+ ${groups.map((g) => html`<option key=${g.id} value=${g.id}>
+ ${g.owner_username ? `${g.name} (@${g.owner_username})` : g.name}</option>`)}
+ </select>
+ </div>
+ ${probe === 'checking' && html`<p class="settings-hint">${t('photosync.checking_group')}</p>`}
+ ${probe === 'offline' && html`<p class="settings-hint">${t('photosync.group_offline')}</p>`}
+ ${folders && !folders.length && html`<p class="settings-hint">${t('photosync.no_folder')}</p>`}
+ ${folders && folders.length > 0 && html`
+ <div class="settings-row">
+ <span class="settings-label">${t('photosync.folder')}</span>
+ <select class="settings-select" value=${folder} onChange=${(e) => setFolder(e.target.value)}>
+ ${folders.map((f) => html`<option key=${f} value=${f}>${f}</option>`)}
+ </select>
+ </div>
+ `}
+ <p class="settings-label" style="margin-top:10px">${t('photosync.albums')}</p>
+ ${(albums || []).map((a) => html`
+ <label class="settings-choice" key=${a.id}>
+ <input type="checkbox" checked=${chosen.has(a.id)} onChange=${() => toggle(a.id)} />
+ <span class="settings-name">
+ <strong>${a.camera ? t('photosync.album_camera') : a.name}</strong>
+ <span class="settings-hint">
+ ${t('photosync.album_count', { n: a.count, size: formatSize(a.bytes) })}</span>
+ </span>
+ </label>
+ `)}
+ <label class="settings-choice">
+ <input type="radio" name="photosync-scope" checked=${existing}
+ onChange=${() => setExisting(true)} />
+ <span><strong>${t('photosync.scope_existing')}</strong></span>
+ </label>
+ <label class="settings-choice">
+ <input type="radio" name="photosync-scope" checked=${!existing}
+ onChange=${() => setExisting(false)} />
+ <span><strong>${t('photosync.scope_new')}</strong></span>
+ </label>
+ <div class="settings-row" style="gap:8px; justify-content:flex-start">
+ <button class="admin-btn" onClick=${start}
+ disabled=${busy || !groupId || !folder || !chosen.size}>${t('photosync.start')}</button>
+ <button class="btn-secondary" onClick=${() => onDone(null)}>${t('dialog.cancel')}</button>
+ </div>
+ `}
+ ${!permitted && html`
+ <button class="btn-secondary" onClick=${() => onDone(null)}>${t('dialog.cancel')}</button>`}
+ ${error && html`<p class="error-msg">${error}</p>`}
+ </div>
+ `;
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
new file mode 100644
index 0000000..154a009
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
@@ -0,0 +1,316 @@
+// Photo backup: this phone's photos sent to one folder of one group, once a
+// day, on an unmetered network (docs/MESHBAY_DESIGN.md §9.12).
+//
+// The phone (platform.photoSync) lists the photos, keeps the ledger of what was
+// sent and hands each photo's bytes over; this module decides when a run is
+// due and does the sending, because the transport and the group key are here.
+// Loaded only where the bridge offers it — the Android application — so no
+// browser and no desktop pays for it.
+//
+// Additive by construction: nothing here deletes, renames or replaces anything
+// on the node. `test_photo_sync.py` reads this file for the operations that
+// would, and refuses them.
+
+import { ConnectionPool } from './connection-pool.js';
+import * as platform from './platform.js';
+import { t } from './i18n.js';
+
+const DAY_MS = 24 * 3600 * 1000;
+// A run cut short by something passing — the node offline, the network gone —
+// is tried again at the next chance, but not more often than this: every
+// return to the foreground would otherwise be a connection attempt.
+const RETRY_MS = 15 * 60 * 1000;
+// A slot the node keeps queued this long means it is busy with others; the
+// backup can wait for tomorrow's run, they cannot.
+const LEASE_WAIT_MS = 10 * 60 * 1000;
+
+// Refusals that will hold at the next run too. Said once (a notification, from
+// the phone) and retried a day later, never at every opening.
+const LASTING = {
+ disk_full: 'photosync.fail_disk_full',
+ root_read_only: 'photosync.fail_read_only',
+ no_writable_root: 'photosync.fail_read_only',
+ no_such_root: 'photosync.fail_no_folder',
+ no_such_directory: 'photosync.fail_no_folder',
+ root_unavailable: 'photosync.fail_unavailable',
+ not_a_member: 'photosync.fail_not_member',
+ permission: 'photosync.fail_permission',
+};
+
+// ── What the settings section shows ─────────────────────────────────────────
+
+let state = { phase: 'idle', done: 0, total: 0 };
+const listeners = new Set();
+
+function setState(patch) {
+ state = { ...state, ...patch };
+ for (const fn of [...listeners]) {
+ try { fn(state); } catch (e) { console.error('[MeshBay] photo backup listener threw:', e); }
+ }
+}
+
+export function subscribe(fn) {
+ listeners.add(fn);
+ fn(state);
+ return () => listeners.delete(fn);
+}
+
+// ── When it runs ────────────────────────────────────────────────────────────
+
+let ctx = null; // { hub, getUser, getBundleKey }
+let running = null;
+let timer = 0;
+let lastAttempt = 0;
+let unmeteredNow = null;
+
+/** True when `status` says a scheduled run should go now. */
+export function isDue(status, now = Date.now()) {
+ if (!status || !status.config) return false;
+ // A lasting refusal waits a day, like a finished run; anything else is
+ // decided by the last run that finished.
+ if (status.failure && status.failureAt && now - status.failureAt < DAY_MS) return false;
+ const last = status.lastCompleted;
+ return last == null || now - last >= DAY_MS || now < last;
+}
+
+/**
+ * Checked at start, on coming back to the foreground, when the network turns
+ * unmetered, and once the next run falls due. Nothing polls.
+ */
+export function startPhotoSync(context) {
+ if (!platform.photoSync.available || ctx) return;
+ ctx = context;
+ document.addEventListener('visibilitychange', onVisible);
+ window.addEventListener('meshbay-network', onNetwork);
+ runPhotoSync();
+}
+
+export function stopPhotoSync() {
+ if (!ctx) return;
+ ctx = null;
+ clearTimeout(timer);
+ document.removeEventListener('visibilitychange', onVisible);
+ window.removeEventListener('meshbay-network', onNetwork);
+}
+
+function onVisible() {
+ if (document.visibilityState === 'visible') runPhotoSync();
+}
+
+function onNetwork(e) {
+ unmeteredNow = !!(e.detail && e.detail.unmetered);
+ if (unmeteredNow) runPhotoSync();
+}
+
+function scheduleNext(status) {
+ clearTimeout(timer);
+ if (!ctx || !status || !status.config) return;
+ const base = Math.max(status.lastCompleted || 0, status.failure ? (status.failureAt || 0) : 0);
+ const wait = base + DAY_MS - Date.now();
+ // A page left open past midnight runs on time; the cap keeps a timer from
+ // being parked for days on a clock that may move.
+ if (wait > 0) timer = setTimeout(runPhotoSync, Math.min(wait, DAY_MS));
+}
+
+/**
+ * A run, if one is due. `force` is "Back up now": due or not. `metered` is the
+ * person's yes to sending over mobile data, asked by the caller.
+ */
+export function runPhotoSync({ force = false, metered = false } = {}) {
+ if (!ctx) return Promise.resolve();
+ if (running) return running;
+ running = (async () => {
+ let status = null;
+ try {
+ status = await platform.photoSync.status();
+ await run(status, force, metered);
+ status = await platform.photoSync.status();
+ } catch (e) {
+ console.warn('[MeshBay] photo backup:', e.message);
+ setState({ phase: 'interrupted', detail: e.message });
+ } finally {
+ running = null;
+ scheduleNext(status);
+ }
+ })();
+ return running;
+}
+
+async function run(status, force, metered) {
+ const cfg = status.config;
+ const user = ctx && ctx.getUser();
+ if (!cfg) { setState({ phase: 'off' }); return; }
+ if (!user || cfg.account !== user.username) { setState({ phase: 'other_account' }); return; }
+ if (status.permission === 'denied') { await lasting('permission'); return; }
+ if (!force && !isDue(status)) {
+ setState({ phase: status.failure ? 'failed' : 'up_to_date', reason: status.failure || '' });
+ return;
+ }
+ unmeteredNow = status.unmetered;
+ if (!status.unmetered && !metered) { setState({ phase: 'waiting_wifi' }); return; }
+ if (!force && Date.now() - lastAttempt < RETRY_MS) return;
+ lastAttempt = Date.now();
+
+ const { items } = await platform.photoSync.plan();
+ if (!items.length) {
+ await platform.photoSync.completed();
+ setState({ phase: 'up_to_date', done: 0, total: 0 });
+ return;
+ }
+
+ setState({ phase: 'connecting', done: 0, total: items.length });
+ const pool = new ConnectionPool(ctx.hub);
+ let conn;
+ try {
+ const bundleKey = await ctx.getBundleKey();
+ conn = await pool.connect(cfg.groupId, user.token, bundleKey, user.username, user.userId);
+ } catch (e) {
+ pool.closeAll();
+ if (e.reason === 'not_a_member') { await lasting('not_a_member'); return; }
+ setState({ phase: 'offline' });
+ return;
+ }
+
+ try {
+ await send(conn.transport, cfg, items, metered);
+ } finally {
+ platform.photoSync.keepAlive(false).catch(() => {});
+ pool.closeAll();
+ }
+}
+
+async function send(transport, cfg, items, metered) {
+ const index = await transport.fetchIndex();
+ const problem = destinationProblem(cfg.folder, index);
+ if (problem) { await lasting(problem); return; }
+
+ // What is already in each folder, by name and size: after a reinstall the
+ // ledger is empty, and a photo already there is recorded, not sent again.
+ const present = new Map();
+ for (const e of index.entries || []) {
+ if (!present.has(e.path)) present.set(e.path, new Map());
+ present.get(e.path).set(e.name, e.size);
+ }
+ const dirs = new Set(index.dirs || []);
+ for (const r of index.roots || []) dirs.add(r.name);
+
+ let done = 0;
+ const progress = () => {
+ setState({ phase: 'sending', done, total: items.length });
+ return t('photosync.notif_progress', { done, total: items.length });
+ };
+ await platform.photoSync.keepAlive(true, progress());
+
+ for (const item of items) {
+ // Off Wi-Fi mid-run: stop after the file in flight. The node keeps the
+ // partial upload, and the next unmetered moment carries on.
+ if (!metered && unmeteredNow === false) { setState({ phase: 'waiting_wifi' }); return; }
+ if (!ctx) return;
+
+ const there = present.get(item.dir);
+ const known = there && [item.name, item.alsoKnownAs]
+ .find((n) => n && there.get(n) === item.size);
+ if (known) {
+ await platform.photoSync.sent(item.token, item.dir, known);
+ } else {
+ try {
+ await makeDirs(transport, cfg.folder, item.dir, dirs);
+ const ack = await upload(transport, item);
+ await platform.photoSync.sent(item.token, ack.dir || item.dir, ack.stored_as || item.name);
+ } catch (e) {
+ if (e.code && LASTING[e.code]) { await lasting(e.code); return; }
+ throw e;
+ }
+ }
+ done += 1;
+ await platform.photoSync.keepAlive(true, progress());
+ }
+ await platform.photoSync.completed();
+ setState({ phase: 'up_to_date', done, total: items.length });
+}
+
+/** One photo, under a slot the node granted — the backup queues like anyone. */
+async function upload(transport, item) {
+ const lease = transport.openTransfer({ kind: 'upload', bytes: item.size });
+ try {
+ let timeout;
+ await Promise.race([
+ lease.acquire(),
+ new Promise((_, reject) => {
+ timeout = setTimeout(() => reject(new Error('The node is busy')), LEASE_WAIT_MS);
+ }),
+ ]).finally(() => clearTimeout(timeout));
+ const file = await platform.photoSync.file(item.token, item.name);
+ return await transport.uploadFile(file, {
+ dir: item.dir, root: item.dir.split('/')[0], tr: lease.tr,
+ });
+ } finally {
+ lease.release();
+ }
+}
+
+/** `<folder>/YYYY/MM`, one level at a time; a level that exists is fine. */
+async function makeDirs(transport, folder, dir, dirs) {
+ let at = folder;
+ for (const name of dir.slice(folder.length + 1).split('/').filter(Boolean)) {
+ const next = `${at}/${name}`;
+ if (!dirs.has(next)) {
+ try {
+ await transport.createDirectory(at, name);
+ } catch (e) {
+ // Somebody else's backup made it a moment ago: what was wanted.
+ if (!/already exists/i.test(e.message || '')) throw e;
+ }
+ dirs.add(next);
+ }
+ at = next;
+ }
+}
+
+/** A reason the chosen folder cannot take photos now, or null. */
+export function destinationProblem(folder, index) {
+ const root = (index.roots || []).find((r) => r.name === folder.split('/')[0]);
+ if (!root) return 'no_such_root';
+ if (!root.writable) return 'root_read_only';
+ if (root.available === false) return 'root_unavailable';
+ if (folder !== root.name && !(index.dirs || []).includes(folder)) return 'no_such_directory';
+ return null;
+}
+
+async function lasting(code) {
+ setState({ phase: 'failed', reason: code });
+ await platform.photoSync.failed(code, t(LASTING[code] || 'photosync.fail_other')).catch(() => {});
+}
+
+// ── For the settings section ────────────────────────────────────────────────
+
+/**
+ * The folders of a group this member can back up into: writable, available,
+ * and shown by the group's Photos tab — a folder outside those would take the
+ * photos and show them nowhere, which reads as a backup that failed.
+ */
+export function backupFolders(ack, index) {
+ const photoDirs = (ack && ack.photo_directories) || [];
+ const roots = new Map((index.roots || [])
+ .filter((r) => r.writable && r.available !== false).map((r) => [r.name, r]));
+ const under = (d) => photoDirs.some((p) => d === p || d.startsWith(`${p}/`));
+ const all = new Set([...(index.dirs || []), ...roots.keys()]);
+ return [...all]
+ .filter((d) => roots.has(d.split('/')[0]) && under(d))
+ .sort((a, b) => a.localeCompare(b));
+}
+
+/** A one-off look at a group, for choosing a folder: connected, read, closed. */
+export async function probeGroup(groupId) {
+ const user = ctx && ctx.getUser();
+ if (!user) throw new Error('Not signed in');
+ const pool = new ConnectionPool(ctx.hub);
+ try {
+ const bundleKey = await ctx.getBundleKey();
+ const conn = await pool.connect(groupId, user.token, bundleKey, user.username, user.userId);
+ const index = await conn.transport.fetchIndex();
+ return { folders: backupFolders(conn.ack, index) };
+ } finally {
+ pool.closeAll();
+ }
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index c70b03a..c5ae9ee 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -230,6 +230,31 @@ export const push = {
},
};
+/**
+ * Backing up this phone's photos to a group (docs/MESHBAY_DESIGN.md §9.12).
+ * Only the Android application has a camera roll to read; `available` is false
+ * in a browser and on a desktop. See photo-sync.js.
+ */
+export const photoSync = {
+ available: Boolean(bridge && bridge.photoSync),
+ status() { return bridge.photoSync.status(); },
+ permit() { return bridge.photoSync.permit(); },
+ albums() { return bridge.photoSync.albums(); },
+ configure(settings) { return bridge.photoSync.configure(settings); },
+ estimate(settings) { return bridge.photoSync.estimate(settings); },
+ plan() { return bridge.photoSync.plan(); },
+ sent(token, dir, name) { return bridge.photoSync.sent(token, dir, name); },
+ completed() { return bridge.photoSync.completed(); },
+ failed(code, text) { return bridge.photoSync.failed(code, text); },
+ keepAlive(on, text) { return bridge.photoSync.keepAlive(on, text); },
+ /** The bytes of a photo the last plan issued, as a File named for the node. */
+ async file(token, name) {
+ const r = await fetch(`/photosync/${encodeURIComponent(token)}`, { cache: 'no-store' });
+ if (!r.ok) throw new Error('This photo is no longer on the phone');
+ return new File([await r.blob()], name);
+ },
+};
+
/** Pick a directory to add as a group root. Returns { path, name } or null. */
export const rootPicker = {
available: Boolean(bridge && bridge.rootPicker),
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
index 985b219..8826520 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
@@ -7,10 +7,15 @@ import * as platform from './platform.js';
import { hubFetch } from './hub-client.js';
import { syncPush, enablePush, disablePush } from './push.js';
import { APPS } from './apps.js';
+import { lazy } from './lazy.js';
import {
PAGE_SIZE_PREF, PAGE_SIZE_DEFAULT, PAGE_SIZE_STEP, PAGE_SIZE_MAX, pageSizeFrom,
} from './pager.js';
+// Photo backup exists on the Android application only, so its module is
+// fetched there and nowhere else (docs/MESHBAY_DESIGN.md §9.12).
+const PhotoSyncSection = lazy(() => import('./photo-sync-settings.js'), 'PhotoSyncSection', null);
+
const PAGE_SIZES = Array.from(
{ length: PAGE_SIZE_MAX / PAGE_SIZE_STEP }, (_, i) => (i + 1) * PAGE_SIZE_STEP);
@@ -296,6 +301,9 @@ export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange
`}
</div>
+ ${platform.photoSync.available && html`
+ <${PhotoSyncSection} user=${user} groups=${groups || []} />`}
+
<div class="settings-section">
<h3 class="settings-heading">${t('settings.defaults')}</h3>
<div class="settings-row">
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 539ac12..ea017fb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2688,6 +2688,11 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.settings-choice input { margin-top: 3px; flex-shrink: 0; }
.settings-choice strong { display: block; font-size: 0.88em; color: var(--text); }
.settings-choice .settings-hint { display: block; margin-top: 2px; }
+/* A group, a folder or an album is named by somebody else and often has no
+ space to break at: without this, one such name widens the whole page on a
+ phone, and everything pinned goes with it (CLAUDE.md). `min-width: 0` lets a
+ flex item shrink below its longest word. */
+.settings-name { overflow-wrap: anywhere; min-width: 0; }
/* A pasted address can be longer than the bubble; breaking it is better than
a message that scrolls sideways. */
diff --git a/packages/meshbay-hub/tests/harness/photo_sync_probe.py b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
new file mode 100644
index 0000000..48d9270
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+"""
+The phone's photo backup section (photo-sync-settings.js), rendered in Chrome
+at phone widths with names nobody would choose for a fixture.
+
+Only the Android application has this section, and its engine is Chromium, so
+one engine is the whole of it. The bridge is a stand-in that answers the way
+the phone does; the group connection (`connection-pool.js`) is replaced in the
+copy by one that hands out a node with long, space-free folder names, because
+those are what widen a page (CLAUDE.md). Three states: setting up, set up with
+a refusal showing, and setting up with only selected photos allowed.
+
+ photo_sync_probe.py prints one JSON object, keyed <state><width>
+"""
+import functools
+import http.server
+import json
+import shutil
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+
+LONG_FOLDER = "Media/Photos/Family_holidays_summer_2026_at_the_seaside_with_everyone_and_the_dog"
+LONG_GROUP = "A_group_name_with_no_spaces_at_all_whatsoever_long"
+LONG_OWNER = "someone_with_a_long_handle"
+
+FAKE_POOL = """
+export class ConnectionPool {
+ async connect() {
+ return {
+ transport: {
+ fetchIndex: async () => ({
+ roots: [{ name: 'Media', writable: true, available: true }],
+ dirs: ['Media/Photos',
+ 'Media/Photos/Some_Band_Complete_Discography_1992-2000_FLAC_and_more_pictures',
+ '__FOLDER__'],
+ }),
+ },
+ ack: { photo_directories: ['Media/Photos'] },
+ };
+ }
+ closeAll() {}
+}
+""".replace("__FOLDER__", LONG_FOLDER)
+
+PROBE = """<!doctype html><html><head><meta charset=utf-8>
+<meta name=viewport content="width=device-width">
+<link rel=stylesheet href="style.css"></head><body>
+<script>
+const note = (m) => {
+ const el = document.documentElement;
+ el.dataset.err = (el.dataset.err || '') + ' ' + m;
+};
+window.addEventListener('error', (e) => note(e.message));
+window.addEventListener('unhandledrejection',
+ (e) => note((e.reason && e.reason.message) || e.reason));
+const MODE = location.hash.slice(1);
+const cfg = {
+ account: 'bob', groupId: 'g1', groupName: '__GROUP__', owner: '__OWNER__',
+ folder: '__FOLDER__', albums: ['cam'], includeExisting: true, since: 0,
+};
+window.meshbay = {
+ hubBase: () => 'https://hub.invalid',
+ photoSync: {
+ status: async () => ({
+ permission: MODE === 'partial' ? 'partial' : 'granted', unmetered: true, sent: 1240,
+ config: MODE === 'configured' ? cfg : null, lastCompleted: Date.now(),
+ failure: MODE === 'configured' ? 'disk_full' : null,
+ }),
+ albums: async () => [
+ { id: 'cam', name: 'Camera', count: 1240, bytes: 4.1e9, camera: true },
+ { id: 'x', name: 'Screenshots_and_saved_images_from_a_messaging_application_long',
+ count: 12, bytes: 3e6, camera: false },
+ ],
+ permit: async () => ({}), configure: async () => ({}),
+ estimate: async () => ({ count: 1, bytes: 1 }), plan: async () => ({ items: [] }),
+ sent: async () => true, completed: async () => ({}), failed: async () => true,
+ keepAlive: async () => true,
+ },
+};
+</script>
+<main class="main"><div id=root></div></main>
+<script type=module>
+import { html, render } from './vendor/htm-preact.js';
+import { initLocale } from './i18n.js';
+import { startPhotoSync } from './photo-sync.js';
+import { PhotoSyncSection } from './photo-sync-settings.js';
+await initLocale();
+startPhotoSync({ hub: '', getUser: () => ({ username: 'bob', token: 't' }),
+ getBundleKey: async () => '' });
+const groups = [{ id: 'g1', name: '__GROUP__', owner_username: '__OWNER__' }];
+render(html`<${PhotoSyncSection} user=${{ username: 'bob', token: 't' }} groups=${groups} />`,
+ document.getElementById('root'));
+setTimeout(() => {
+ if (MODE === 'configured') return;
+ [...document.querySelectorAll('button')].find((b) => b.textContent.includes('Set up')).click();
+ setTimeout(() => {
+ const sel = document.querySelector('select');
+ sel.value = 'g1';
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
+ }, 300);
+}, 500);
+</script></body></html>
+""".replace("__GROUP__", LONG_GROUP).replace("__OWNER__", LONG_OWNER) \
+ .replace("__FOLDER__", LONG_FOLDER)
+
+OUTER = """<!doctype html><html><body style="margin:0">
+<script>
+for (const m of ['setup', 'configured', 'partial']) for (const w of [360, 420]) {
+ const f = document.createElement('iframe');
+ f.id = m + w;
+ f.style.cssText = `width:${w}px;height:900px;border:0;display:block`;
+ f.src = 'probe.html#' + m;
+ document.body.appendChild(f);
+}
+setTimeout(() => {
+ const out = {};
+ for (const f of document.querySelectorAll('iframe')) {
+ const d = f.contentDocument;
+ const vw = f.contentWindow.innerWidth;
+ const wide = [...d.querySelectorAll('#root *')]
+ .filter((e) => e.getBoundingClientRect().right > vw + 0.5)
+ .map((e) => `${e.tagName}.${e.className} ${Math.round(e.getBoundingClientRect().right)}`);
+ out[f.id] = {
+ vw, scrollW: d.documentElement.scrollWidth, wide: wide.slice(0, 6),
+ text: d.querySelector('#root').innerText.slice(0, 600),
+ err: d.documentElement.dataset.err || '',
+ checked: [...d.querySelectorAll('#root input:checked')]
+ .map((i) => i.closest('label').innerText.split('\\n')[0]),
+ };
+ }
+ const pre = document.createElement('pre');
+ pre.id = 'out';
+ pre.textContent = JSON.stringify(out);
+ document.body.appendChild(pre);
+}, 4000);
+</script></body></html>
+"""
+
+
+class _Quiet(http.server.SimpleHTTPRequestHandler):
+ def log_message(self, *args) -> None:
+ pass
+
+
+def main() -> int:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp) / "static"
+ shutil.copytree(STATIC, root)
+ (root / "connection-pool.js").write_text(FAKE_POOL, encoding="utf-8")
+ (root / "probe.html").write_text(PROBE, encoding="utf-8")
+ (root / "outer.html").write_text(OUTER, encoding="utf-8")
+ handler = functools.partial(_Quiet, directory=str(root))
+ with socketserver.TCPServer(("127.0.0.1", 0), handler) as httpd:
+ port = httpd.server_address[1]
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
+ try:
+ dom = subprocess.run(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-first-run",
+ f"--user-data-dir={tmp}/profile", "--virtual-time-budget=8000",
+ "--dump-dom", f"http://127.0.0.1:{port}/outer.html"],
+ capture_output=True, text=True, timeout=120).stdout
+ finally:
+ httpd.shutdown()
+ start = dom.find('<pre id="out">')
+ if start < 0:
+ print(json.dumps({"error": "no result in the page"}))
+ return 1
+ body = dom[start + len('<pre id="out">'):dom.index("</pre>", start)]
+ for entity, char in (("&quot;", '"'), ("&lt;", "<"), ("&gt;", ">"), ("&amp;", "&")):
+ body = body.replace(entity, char)
+ print(json.dumps(json.loads(body)))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/packages/meshbay-hub/tests/test_android_cast.py b/packages/meshbay-hub/tests/test_android_cast.py
index ebcdec4..82b36d6 100644
--- a/packages/meshbay-hub/tests/test_android_cast.py
+++ b/packages/meshbay-hub/tests/test_android_cast.py
@@ -113,11 +113,12 @@ def test_relay_calls_keep_their_order():
" || frame.channel == BinaryFrame.CAST_FILE") in _read(SRC / "bridge" / "Channels.kt")
-def test_screen_off_survival_is_switched_on_only_while_casting_or_playing():
+def test_screen_off_survival_is_switched_on_only_while_casting_playing_or_backing_up():
activity = _read(SRC / "MainActivity.kt")
keep = activity.split("private fun keepAlive()", 1)[1].split("\n }\n", 1)[0]
assert "val on = casting || playing" in keep
- assert "web.keepVisible = on" in keep
+ # A photo backup holds the page too, through its own service (§9.12).
+ assert "web.keepVisible = on || syncing" in keep
assert "if (!on) { stopService(service); return }" in keep
assert "startForegroundService(service)" in keep
assert activity.count("keepVisible =") == 1, "the page is kept visible from one place only"
@@ -126,6 +127,8 @@ def test_screen_off_survival_is_switched_on_only_while_casting_or_playing():
manifest = _read(MAIN / "AndroidManifest.xml")
assert 'android:name=".cast.CastService"' in manifest
assert 'android:foregroundServiceType="mediaPlayback"' in manifest
+ assert 'android:name=".photos.BackupService"' in manifest
+ assert 'android:foregroundServiceType="dataSync"' in manifest
def test_the_receiver_is_given_what_the_desktop_gives_it():
diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py
index 129732c..5262aae 100644
--- a/packages/meshbay-hub/tests/test_android_shell.py
+++ b/packages/meshbay-hub/tests/test_android_shell.py
@@ -135,14 +135,17 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each():
preload = set(re.findall(r"ipcRenderer\.invoke\('([\w:-]+)'", preload_js))
native = set()
for path in (SRC / "bridge" / "Channels.kt", SRC / "bridge" / "KeyChannels.kt",
- SRC / "cast" / "CastChannels.kt", SRC / "notify" / "PushChannels.kt"):
+ SRC / "cast" / "CastChannels.kt", SRC / "notify" / "PushChannels.kt",
+ SRC / "photos" / "PhotoChannels.kt"):
native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(path), flags=re.M))
shim = _shim_channels()
assert shim, "no channel found in the shim"
- # A phone has a push distributor to talk to and a desktop does not; that
- # family is the one the desktop lacks rather than the one it shares.
- phone_only = {c for c in shim if c.startswith("push:")}
- assert phone_only, "the push channels are gone from the shim"
+ # A phone has a push distributor to talk to and a camera roll to back up,
+ # and a desktop has neither; those families are the ones the desktop lacks
+ # rather than the ones it shares.
+ phone_only = {c for c in shim if c.startswith(("push:", "photosync:"))}
+ assert any(c.startswith("push:") for c in phone_only), "the push channels are gone"
+ assert any(c.startswith("photosync:") for c in phone_only), "the backup channels are gone"
assert shim - phone_only <= preload, f"channels the desktop does not have: {shim - preload}"
assert shim == native, f"shim and native disagree: {shim ^ native}"
diff --git a/packages/meshbay-hub/tests/test_first_load_is_lean.py b/packages/meshbay-hub/tests/test_first_load_is_lean.py
index 74cd880..25638ca 100644
--- a/packages/meshbay-hub/tests/test_first_load_is_lean.py
+++ b/packages/meshbay-hub/tests/test_first_load_is_lean.py
@@ -24,6 +24,8 @@ ON_DEMAND = {
"chat-app-settings.js", "video-app-settings.js", "music-app-settings.js",
"photos-app-settings.js", "helloworld-app-settings.js",
"video-player.js", "group-settings.js", "search-page.js",
+ # The Android application's photo backup: no browser or desktop has it.
+ "photo-sync.js", "photo-sync-settings.js",
}
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index 0172127..73bac38 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -54,6 +54,8 @@ STATIC_FILES = [
"menu.js", "playlist-menu.js",
# The page's own confirm/alert, mounted outside the app tree.
"ask.js",
+ # The Android application's photo backup, loaded by Settings on demand.
+ "settings-page.js", "photo-sync-settings.js",
]
pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable")
diff --git a/packages/meshbay-hub/tests/test_photo_sync.py b/packages/meshbay-hub/tests/test_photo_sync.py
new file mode 100644
index 0000000..f7852f4
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_photo_sync.py
@@ -0,0 +1,307 @@
+"""
+The phone's photo backup, as the page runs it (docs/MESHBAY_DESIGN.md §9.12).
+
+Run under Node against the real `photo-sync.js`, with the two things it talks
+to replaced: the Android bridge (`window.meshbay.photoSync`, which lists the
+photos and keeps the ledger) and the group connection (`connection-pool.js`,
+swapped in the copy for one that hands out a scripted node). What is pinned is
+what the person was promised: once a day, on an unmetered network, newest
+first, nothing ever deleted on the node, and a refusal that will hold tomorrow
+said once rather than retried at every opening.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+SYNC = STATIC / "photo-sync.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not SYNC.exists(),
+ reason="node or the SPA sources are not available")
+
+DAY = 24 * 3600 * 1000
+
+FAKE_POOL = r"""
+export class ConnectionPool {
+ constructor(hub) { globalThis.fake.pools.push(hub); }
+ async connect(groupId) {
+ if (globalThis.fake.refuseConnect) {
+ const e = new Error('refused'); e.reason = globalThis.fake.refuseConnect; throw e;
+ }
+ globalThis.fake.log.push(['connect', groupId]);
+ return { transport: globalThis.fake.transport, ack: globalThis.fake.ack };
+ }
+ closeAll() { globalThis.fake.log.push(['close']); }
+}
+"""
+
+PRELUDE = r"""
+const fake = globalThis.fake = { log: [], pools: [], refuseConnect: null, uploadError: null,
+ afterUpload: null };
+const win = new EventTarget();
+const doc = new EventTarget();
+doc.visibilityState = 'visible';
+globalThis.window = win;
+globalThis.document = doc;
+globalThis.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} };
+globalThis.fetch = async (url) => {
+ fake.log.push(['fetch', url]);
+ return { ok: true, blob: async () => new Blob([new Uint8Array(4)]) };
+};
+
+const NOW = Date.now();
+fake.status = {
+ permission: 'granted', unmetered: true, sent: 0, now: NOW,
+ config: { account: 'bob', groupId: 'g1', groupName: 'Family', owner: 'alice',
+ folder: 'Media/Photos/Bob', albums: ['cam'], includeExisting: true, since: 0 },
+ lastCompleted: null, failure: null, failureAt: null,
+};
+fake.items = [
+ { token: 't1', name: 'IMG_3.jpg', dir: 'Media/Photos/Bob/2026/10', size: 30, edited: false,
+ alsoKnownAs: 'IMG_3-edited-x.jpg' },
+ { token: 't2', name: 'IMG_2.jpg', dir: 'Media/Photos/Bob/2026/09', size: 20, edited: false,
+ alsoKnownAs: 'IMG_2-edited-x.jpg' },
+ { token: 't3', name: 'IMG_1.jpg', dir: 'Media/Photos/Bob/2026/09', size: 10, edited: false,
+ alsoKnownAs: 'IMG_1-edited-x.jpg' },
+];
+fake.index = {
+ roots: [{ name: 'Media', writable: true, available: true }],
+ dirs: ['Media/Photos', 'Media/Photos/Bob', 'Media/Photos/Bob/2026', 'Media/Photos/Bob/2026/09'],
+ entries: [{ path: 'Media/Photos/Bob/2026/09', name: 'IMG_1.jpg', size: 10 }],
+};
+fake.ack = { photo_directories: ['Media/Photos'] };
+fake.transport = {
+ connected: true,
+ async fetchIndex() { return fake.index; },
+ async createDirectory(at, name) { fake.log.push(['mkdir', `${at}/${name}`]); return {}; },
+ openTransfer({ kind, bytes }) {
+ fake.log.push(['lease', kind, bytes]);
+ return { tr: 'tr-' + bytes, acquire: async () => {},
+ release: () => fake.log.push(['release', bytes]) };
+ },
+ async uploadFile(file, opts) {
+ fake.log.push(['upload', opts.dir, file.name, opts.tr]);
+ if (fake.uploadError) { const e = new Error('refused'); e.code = fake.uploadError; throw e; }
+ if (fake.afterUpload) fake.afterUpload();
+ return { stored_as: file.name, dir: opts.dir };
+ },
+};
+win.meshbay = { photoSync: {
+ status: async () => fake.status,
+ plan: async () => { fake.log.push(['plan']); return { items: fake.items }; },
+ sent: async (token, dir, name) => { fake.log.push(['sent', token, dir, name]); return true; },
+ completed: async () => {
+ fake.log.push(['completed']);
+ fake.status.lastCompleted = Date.now();
+ return fake.status;
+ },
+ failed: async (code) => { fake.log.push(['failed', code]); return true; },
+ keepAlive: async (on) => { fake.log.push(['keep', on]); return true; },
+} };
+"""
+
+
+def _run(body, tmp_path):
+ root = tmp_path / "static"
+ shutil.copytree(STATIC, root)
+ (root / "package.json").write_text('{"type":"module"}', encoding="utf-8")
+ (root / "connection-pool.js").write_text(FAKE_POOL, encoding="utf-8")
+ script = tmp_path / "case.mjs"
+ script.write_text(
+ PRELUDE
+ + f"const m = await import('{(root / 'photo-sync.js').as_uri()}');\n"
+ + "const ctx = { hub: 'https://hub.invalid', getUser: () => ({ username: 'bob', "
+ "userId: 'u-bob', token: 'tok' }), getBundleKey: async () => 'bk' };\n"
+ + body
+ # The next run's timer would keep Node alive for a day.
+ + "\nm.stopPhotoSync();\nconsole.log(JSON.stringify(fake.log));\n",
+ encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True,
+ encoding="utf-8", timeout=30)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout.strip().splitlines()[-1])
+
+
+def _start(extra=""):
+ return extra + "\nm.startPhotoSync(ctx);\nawait m.runPhotoSync();\n"
+
+
+def _of(log, kind):
+ return [e[1:] for e in log if e[0] == kind]
+
+
+# ── a run ───────────────────────────────────────────────────────────────────
+
+def test_a_due_run_sends_newest_first_and_records_each(tmp_path):
+ log = _run(_start(), tmp_path)
+ # IMG_1 is already in the folder (a reinstall's empty ledger): recorded,
+ # not sent again.
+ assert _of(log, "upload") == [
+ ["Media/Photos/Bob/2026/10", "IMG_3.jpg", "tr-30"],
+ ["Media/Photos/Bob/2026/09", "IMG_2.jpg", "tr-20"],
+ ]
+ assert _of(log, "sent") == [
+ ["t1", "Media/Photos/Bob/2026/10", "IMG_3.jpg"],
+ ["t2", "Media/Photos/Bob/2026/09", "IMG_2.jpg"],
+ ["t3", "Media/Photos/Bob/2026/09", "IMG_1.jpg"],
+ ]
+ assert ["completed"] in log
+ assert _of(log, "keep")[0] == [True] and _of(log, "keep")[-1] == [False], (
+ "the screen-off keep-alive was not released after the run")
+
+
+def test_only_missing_folders_are_made_and_parents_first(tmp_path):
+ log = _run(_start(), tmp_path)
+ assert _of(log, "mkdir") == [["Media/Photos/Bob/2026/10"]]
+
+
+def test_every_upload_holds_a_slot_and_gives_it_back(tmp_path):
+ """The backup queues like any member: one slot, returned every time."""
+ log = _run(_start(), tmp_path)
+ assert _of(log, "lease") == [["upload", 30], ["upload", 20]]
+ assert _of(log, "release") == [[30], [20]]
+
+
+def test_an_edit_under_its_own_name_counts_as_already_there(tmp_path):
+ log = _run(_start("""
+fake.index.entries.push({ path: 'Media/Photos/Bob/2026/10', name: 'IMG_3-edited-x.jpg', size: 30 });
+"""), tmp_path)
+ assert ["t1", "Media/Photos/Bob/2026/10", "IMG_3-edited-x.jpg"] in _of(log, "sent")
+ assert all(u[1] != "IMG_3.jpg" for u in _of(log, "upload"))
+
+
+# ── when ────────────────────────────────────────────────────────────────────
+
+def test_nothing_happens_before_a_day_has_passed(tmp_path):
+ log = _run(_start("fake.status.lastCompleted = Date.now() - 3600 * 1000;"), tmp_path)
+ assert ["plan"] not in log and _of(log, "connect") == []
+
+
+def test_a_day_later_it_runs(tmp_path):
+ log = _run(_start(f"fake.status.lastCompleted = Date.now() - {DAY} - 1000;"), tmp_path)
+ assert ["completed"] in log
+
+
+def test_back_up_now_runs_whenever(tmp_path):
+ log = _run("""
+fake.status.lastCompleted = Date.now() - 60000;
+m.startPhotoSync(ctx);
+await m.runPhotoSync();
+fake.log.length = 0;
+await m.runPhotoSync({ force: true });
+""", tmp_path)
+ assert ["completed"] in log
+
+
+def test_on_a_metered_network_a_scheduled_run_waits(tmp_path):
+ log = _run(_start("fake.status.unmetered = false;"), tmp_path)
+ assert ["plan"] not in log
+
+
+def test_a_run_the_person_allowed_on_mobile_data_goes(tmp_path):
+ log = _run("""
+fake.status.unmetered = false;
+m.startPhotoSync(ctx);
+await m.runPhotoSync();
+fake.log.length = 0;
+await m.runPhotoSync({ force: true, metered: true });
+""", tmp_path)
+ assert ["completed"] in log
+
+
+def test_leaving_wifi_stops_after_the_file_in_flight(tmp_path):
+ log = _run(_start("""
+fake.afterUpload = () => {
+ fake.afterUpload = null;
+ window.dispatchEvent(new CustomEvent('meshbay-network', { detail: { unmetered: false } }));
+};
+"""), tmp_path)
+ assert len(_of(log, "upload")) == 1, "it went on sending over mobile data"
+ assert ["completed"] not in log, "an unfinished run was recorded as the day's run"
+
+
+def test_wifi_coming_back_starts_a_waiting_run(tmp_path):
+ log = _run("""
+fake.status.unmetered = false;
+m.startPhotoSync(ctx);
+await m.runPhotoSync();
+fake.status.unmetered = true;
+window.dispatchEvent(new CustomEvent('meshbay-network', { detail: { unmetered: true } }));
+await m.runPhotoSync();
+""", tmp_path)
+ assert ["completed"] in log
+
+
+# ── refusals ────────────────────────────────────────────────────────────────
+
+@pytest.mark.parametrize("code", ["disk_full", "root_read_only", "no_such_directory",
+ "root_unavailable"])
+def test_a_lasting_refusal_stops_and_is_said_once(tmp_path, code):
+ log = _run(_start(f"fake.uploadError = '{code}';"), tmp_path)
+ assert _of(log, "failed") == [[code]]
+ assert len(_of(log, "upload")) == 1, "it carried on into the same refusal"
+ assert ["completed"] not in log
+
+
+def test_after_a_lasting_refusal_it_waits_a_day(tmp_path):
+ log = _run(_start("""
+fake.status.failure = 'disk_full';
+fake.status.failureAt = Date.now() - 3600 * 1000;
+"""), tmp_path)
+ assert ["plan"] not in log
+
+
+def test_a_member_removed_from_the_group_is_told_once(tmp_path):
+ log = _run(_start("fake.refuseConnect = 'not_a_member';"), tmp_path)
+ assert _of(log, "failed") == [["not_a_member"]]
+
+
+def test_an_offline_node_is_not_a_lasting_refusal(tmp_path):
+ log = _run(_start("fake.refuseConnect = 'timeout';"), tmp_path)
+ assert _of(log, "failed") == [] and ["completed"] not in log
+
+
+def test_a_folder_no_longer_writable_is_found_before_sending(tmp_path):
+ log = _run(_start("fake.index.roots[0].writable = false;"), tmp_path)
+ assert _of(log, "failed") == [["root_read_only"]]
+ assert _of(log, "upload") == []
+
+
+def test_another_account_on_the_phone_sends_nothing(tmp_path):
+ log = _run(_start("fake.status.config.account = 'carol';"), tmp_path)
+ assert ["plan"] not in log
+
+
+# ── choosing a folder ───────────────────────────────────────────────────────
+
+def test_only_writable_folders_the_photos_tab_shows_are_offered(tmp_path):
+ log = _run("""
+fake.log.push(['folders', m.backupFolders(
+ { photo_directories: ['Media/Photos', 'Archive/Pictures'] },
+ { roots: [{ name: 'Media', writable: true, available: true },
+ { name: 'Archive', writable: false, available: true },
+ { name: 'Ejected', writable: true, available: false }],
+ dirs: ['Media/Films', 'Media/Photos', 'Media/Photos/Bob', 'Archive/Pictures',
+ 'Ejected/Photos'] })]);
+""", tmp_path)
+ assert _of(log, "folders") == [[["Media/Photos", "Media/Photos/Bob"]]]
+
+
+# ── nothing is ever removed ─────────────────────────────────────────────────
+
+def test_the_backup_has_no_way_to_remove_anything_on_the_node():
+ """
+ Additive by construction: a photo deleted on the phone stays in the group,
+ and nothing the sync does can take one away. Read from the source, because
+ the property is the absence of a call.
+ """
+ src = SYNC.read_text(encoding="utf-8")
+ for op in ("deleteFile", "deleteDirectory", "removeRoot", "file_delete", "dir_delete",
+ "root_remove", "rename"):
+ assert not re.search(rf"\b{op}\b", src), f"photo-sync.js reaches {op}"
diff --git a/packages/meshbay-hub/tests/test_photo_sync_ui.py b/packages/meshbay-hub/tests/test_photo_sync_ui.py
new file mode 100644
index 0000000..bd512c1
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_photo_sync_ui.py
@@ -0,0 +1,55 @@
+"""
+The photo backup section in Settings, measured in Chrome at phone widths
+(harness/photo_sync_probe.py). Android's WebView is the only engine that ever
+shows it.
+
+A group, a folder or an album is named by somebody else and often has no space
+in it; one such name made a 360px page 634px wide before `.settings-name`, and
+a page wider than the screen takes every pinned header with it (CLAUDE.md).
+"""
+
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+PROBE = Path(__file__).parent / "harness" / "photo_sync_probe.py"
+
+
+@pytest.fixture(scope="module")
+def probe():
+ if shutil.which("google-chrome") is None:
+ pytest.skip("chrome is not available")
+ proc = subprocess.run([sys.executable, str(PROBE)], capture_output=True, text=True,
+ timeout=240)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ return json.loads(proc.stdout)
+
+
+def test_it_renders_without_an_error(probe):
+ for state, r in probe.items():
+ assert r["err"] == "", f"{state}: {r['err']}"
+ assert r["text"].strip(), f"{state}: nothing was drawn"
+
+
+def test_no_name_widens_the_page_on_a_phone(probe):
+ for state, r in probe.items():
+ assert r["scrollW"] <= r["vw"], (
+ f"{state}: {r['scrollW']}px of page in a {r['vw']}px window — {r['wide']}")
+
+
+def test_the_camera_and_the_photos_already_there_are_the_defaults(probe):
+ for state in ("setup360", "partial360"):
+ assert probe[state]["checked"] == [
+ "Camera", "The photos already on this phone, then new ones"], probe[state]["checked"]
+
+
+def test_a_refusal_is_said_where_the_backup_is(probe):
+ assert "the node's disk is full" in probe["configured360"]["text"]
+
+
+def test_limited_access_is_said_rather_than_failing_quietly(probe):
+ assert "Access is limited to the photos you selected" in probe["partial360"]["text"]
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index c1ca36b..bb39966 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -46,6 +46,8 @@ SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js",
STATIC / "helloworld-app-settings.js",
STATIC / "menu.js", STATIC / "playlist-menu.js",
STATIC / "auth-page.js", STATIC / "explore-page.js",
+ STATIC / "settings-page.js", STATIC / "photo-sync-settings.js",
+ STATIC / "photo-sync.js",
CREATE_GROUP]
pytestmark = pytest.mark.skipif(