diff options
Diffstat (limited to 'packages/meshbay-android/app')
13 files changed, 608 insertions, 1 deletions
diff --git a/packages/meshbay-android/app/build.gradle.kts b/packages/meshbay-android/app/build.gradle.kts index 8afbc1a..7f7ba3c 100644 --- a/packages/meshbay-android/app/build.gradle.kts +++ b/packages/meshbay-android/app/build.gradle.kts @@ -57,6 +57,11 @@ dependencies { // run time; where they are absent the page is offered no cast at all. implementation("com.google.android.gms:play-services-cast-framework:22.3.1") implementation("androidx.mediarouter:mediarouter:1.8.1") + // Notifications are fetched with nothing to install; this is only for a + // phone that already has a UnifiedPush distributor (ntfy, …), which then + // makes them instant, encrypted to the phone (RFC 8291) — no vendor push + // service. Apache-2.0, as is Tink, which it brings for the decryption. + implementation("org.unifiedpush.android:connector:3.3.5") testImplementation("junit:junit:4.13.2") // Android's org.json is a stub on the JVM; the unit tests need the real one. testImplementation("org.json:json:20260814") diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml index 2eae3ea..0c234aa 100644 --- a/packages/meshbay-android/app/src/main/AndroidManifest.xml +++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml @@ -8,6 +8,11 @@ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> + <!-- Notifications from the hub, fetched or pushed; asked for when the + person turns them on, never at start. --> + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> + <!-- The periodic fetch survives a reboot (JobInfo.setPersisted). --> + <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <!-- No backup of any kind: the keys are wrapped by a Keystore key that a restore cannot bring with it, so a backed-up store is one that silently @@ -36,6 +41,19 @@ android:name=".cast.CastService" android:exported="false" android:foregroundServiceType="mediaPlayback" /> + <!-- Not exported: the connector's own receiver takes the distributor's + broadcasts and hands them here inside the application. --> + <service + android:name=".notify.PushReceiver" + android:exported="false"> + <intent-filter> + <action android:name="org.unifiedpush.android.connector.PUSH_EVENT" /> + </intent-filter> + </service> + <service + android:name=".notify.PollJob" + android:exported="false" + android:permission="android.permission.BIND_JOB_SERVICE" /> <meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" android:value="org.meshbay.client.cast.CastOptionsProvider" /> diff --git a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js index b71b5e8..82e556d 100644 --- a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js +++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js @@ -186,6 +186,17 @@ keepAlive: (on) => call('playback:keep-alive', on === true), }, + // Notifications while closed: fetched, or pushed through a UnifiedPush + // distributor when the phone has one. Phone-only: the + // desktop preload has no counterpart, so `platform.push` is absent there. + push: { + status: () => call('push:status'), + enable: () => call('push:enable'), + disable: () => call('push:disable'), + remember: (subscription, account, secret, since) => + call('push:remember', subscription, account, secret, since), + }, + // Where downloads go, chosen once. A display name comes back, never a URI. folder: { choose: () => call('folder:choose'), diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt index 0fa63d6..dad8462 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt @@ -32,6 +32,9 @@ import org.meshbay.client.cast.CastChannels import org.meshbay.client.cast.CastService import org.meshbay.client.hub.HubClient import org.meshbay.client.keys.SecretStore +import org.meshbay.client.notify.Notifier +import org.meshbay.client.notify.PushChannels +import org.meshbay.client.notify.PushState import org.meshbay.client.save.SaveSinks import org.meshbay.client.shell.Pickers import org.meshbay.client.shell.ShellWebView @@ -62,6 +65,7 @@ class MainActivity : Activity() { private var playing = false private var fullscreen: View? = null private var fullscreenCallback: WebChromeClient.CustomViewCallback? = null + private var pendingLink: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -89,13 +93,28 @@ class MainActivity : Activity() { tell = { m -> runOnUiThread { android.widget.Toast.makeText(this, m, android.widget.Toast.LENGTH_LONG).show() } }) channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } }, hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves, - cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } }) + cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } }, + push = PushChannels(this, PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE)), + channelNames = { mapOf( + Notifier.CHANNEL_CHAT to text.get("push.channel_chat", channels.locale), + Notifier.CHANNEL_OTHER to text.get("push.channel_other", channels.locale)) })) 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) } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + Notifier.linkOf(intent)?.let { if (::web.isInitialized) goTo(it) } + } + + /** A route inside the page (`#/…`), checked by Notifier — never a URL to load. */ + private fun goTo(link: String) = web.evaluateJavascript("location.hash = ${JSONObject.quote(link)};", null) + private fun configure(web: WebView) { WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) web.settings.apply { @@ -116,6 +135,10 @@ class MainActivity : Activity() { .addPathHandler(UiAssets.PREFIX, UiAssets(this)) .build() web.webViewClient = object : WebViewClientCompat() { + override fun onPageFinished(view: WebView, url: String) { + pendingLink?.let { pendingLink = null; goTo(it) } + } + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? { val url = request.url if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused() diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt index f7094a0..5f202ba 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt @@ -3,6 +3,7 @@ package org.meshbay.client.bridge import org.json.JSONArray import org.meshbay.client.cast.CastChannels import org.meshbay.client.hub.HubClient +import org.meshbay.client.notify.PushChannels import org.meshbay.client.save.BinaryFrame import org.meshbay.client.save.SaveSinks import java.net.Inet4Address @@ -26,6 +27,7 @@ class Channels( private val saves: SaveSinks? = null, private val cast: CastChannels? = null, private val onPlayback: (Boolean) -> Unit = {}, + private val push: PushChannels? = null, ) { @Volatile var locale = "en" private set @@ -53,6 +55,7 @@ class Channels( else -> when { keys != null && keys.handles(channel) -> keys.call(channel, args) cast != null && cast.handles(channel) -> cast.call(channel, args) + push != null && push.handles(channel) -> push.call(channel, args) else -> throw Refused("Refused: no such channel") } } diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt new file mode 100644 index 0000000..7e6fce6 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt @@ -0,0 +1,91 @@ +package org.meshbay.client.notify + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import org.json.JSONObject +import org.meshbay.client.MainActivity +import org.meshbay.client.R + +/** + * A notification from the hub, pushed or fetched, drawn by the system. + * + * The text is the hub's own line ("… posted in …") — the hub never holds a + * message, so neither does a push. A conversation is one entry per group, + * replaced as it goes on, as it is one row on the hub. + */ +object Notifier { + const val CHANNEL_CHAT = "chat" + const val CHANNEL_OTHER = "account" + const val EXTRA_LINK = "org.meshbay.client.LINK" + private val LINK = Regex("^#/[A-Za-z0-9/_-]{0,200}$") + + data class Shown(val channel: String, val tag: String, val title: String, val link: String?, + val id: Long, val createdAt: String) + + /** + * What to draw for a payload, or null for one that is not ours to draw. + * It was decrypted with this phone's key or fetched from the signed-in hub + * with this phone's secret; it is still checked like any input. + */ + fun parse(content: ByteArray): Shown? { + val o = try { JSONObject(String(content, Charsets.UTF_8)) } catch (e: Exception) { return null } + if (o.optInt("v", 0) != 1) return null + val kind = o.optString("kind", "").take(32) + val title = o.optString("title", "").take(256).ifBlank { return null } + val group = if (o.isNull("group_id")) "" else o.optString("group_id", "").take(64) + val link = (if (o.isNull("link")) null else o.optString("link", null))?.takeIf { LINK.matches(it) } + val chat = kind == "chat_message" && group.isNotEmpty() + val id = o.optLong("id", 0) + val createdAt = o.optString("created_at", "").take(40) + val tag = if (chat) "chat:$group" else "n:$id" + return Shown(if (chat) CHANNEL_CHAT else CHANNEL_OTHER, tag, title, link, id, createdAt) + } + + /** Draw it unless it was already drawn — pushed, then fetched, is one line. */ + fun deliver(context: Context, state: PushState, shown: Shown) { + if (state.firstSight(shown.id, shown.createdAt)) show(context, shown) + } + + /** + * The two channels, named in the person's language. Called with names when + * the page turns push on; from the receiver with none, only to make sure a + * channel exists, so a localized name is never overwritten by the fallback. + */ + fun ensureChannels(context: Context, names: Map<String, String>? = null) { + val nm = context.getSystemService(NotificationManager::class.java) + for ((id, fallback, importance) in listOf( + Triple(CHANNEL_CHAT, "Messages", NotificationManager.IMPORTANCE_DEFAULT), + Triple(CHANNEL_OTHER, "Invitations and account", NotificationManager.IMPORTANCE_DEFAULT), + )) { + if (names == null && nm.getNotificationChannel(id) != null) continue + nm.createNotificationChannel(NotificationChannel(id, names?.get(id) ?: fallback, importance)) + } + } + + fun show(context: Context, shown: Shown) { + ensureChannels(context) + val open = Intent(context, MainActivity::class.java) + .setAction(Intent.ACTION_VIEW) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + shown.link?.let { open.putExtra(EXTRA_LINK, it) } + val pending = PendingIntent.getActivity(context, shown.tag.hashCode(), open, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + val n = Notification.Builder(context, shown.channel) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle("MeshBay") + .setContentText(shown.title) + .setStyle(Notification.BigTextStyle().bigText(shown.title)) + .setContentIntent(pending) + .setAutoCancel(true) + .setCategory(if (shown.channel == CHANNEL_CHAT) Notification.CATEGORY_MESSAGE else Notification.CATEGORY_SOCIAL) + .build() + context.getSystemService(NotificationManager::class.java).notify(shown.tag, 1, n) + } + + /** A link from a notification the shell itself drew, checked again on the way in. */ + fun linkOf(intent: Intent?): String? = intent?.getStringExtra(EXTRA_LINK)?.takeIf { LINK.matches(it) } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt new file mode 100644 index 0000000..0998d00 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt @@ -0,0 +1,65 @@ +package org.meshbay.client.notify + +import android.app.job.JobParameters +import android.app.job.JobService +import android.content.Context +import android.util.Log +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import org.meshbay.client.bridge.Bridge +import org.meshbay.client.hub.HubClient +import java.util.concurrent.TimeUnit + +/** + * One fetch of what is new (`POST /v1/push/poll`), page running or not. + * + * Authenticated by the row's poll secret, never a session: what this keeps for + * running in the background reads notification lines and nothing else. It goes + * to the hub the application is signed in to, and only there. + */ +class PollJob : JobService() { + @Volatile private var worker: Thread? = null + + override fun onStartJob(params: JobParameters): Boolean { + worker = Thread { + try { fetch() } catch (e: Exception) { Log.w(Bridge.TAG, "poll failed: ${e.javaClass.simpleName}") } + jobFinished(params, false) + }.apply { start() } + return true + } + + override fun onStopJob(params: JobParameters): Boolean { + worker?.interrupt() + return false + } + + private fun fetch() { + val state = PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE)) + val target = state.pollTarget() ?: return + val base = HubClient(getSharedPreferences("shell", Context.MODE_PRIVATE)).base + val url = base.toHttpUrlOrNull()?.newBuilder()?.encodedPath("/v1/push/poll")?.build() ?: return + val body = JSONObject().put("id", target.id).put("secret", target.secret).put("since", target.since) + .toString().toRequestBody("application/json".toMediaType()) + val http = OkHttpClient.Builder().callTimeout(30, TimeUnit.SECONDS).followRedirects(false).build() + http.newCall(Request.Builder().url(url).post(body).build()).execute().use { r -> + when { + // The row is gone — signed out elsewhere, or deleted: the page + // registers again when it next runs, if push is still on. + r.code == 404 -> state.forgetSubscription() + r.isSuccessful -> { + val list = JSONObject(r.body.string()).optJSONArray("notifications") ?: return + for (i in 0 until list.length()) { + val raw = list.optJSONObject(i)?.toString()?.toByteArray() ?: continue + val shown = Notifier.parse(raw) ?: continue + Notifier.deliver(this, state, shown) + state.advance(shown.createdAt) + } + } + } + } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt new file mode 100644 index 0000000..8f58512 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt @@ -0,0 +1,29 @@ +package org.meshbay.client.notify + +import android.app.job.JobInfo +import android.app.job.JobScheduler +import android.content.ComponentName +import android.content.Context + +/** + * The periodic fetch, through the system's own job scheduler: no library and + * nothing the platform does not already run. Android decides the exact moment + * (fifteen minutes is the shortest period it allows, and Doze stretches it); + * a phone that also gets pushes keeps a slow fetch as a net under them. + */ +object Poller { + private const val JOB_ID = 4208 + private const val FETCHING_MS = 15L * 60 * 1000 + private const val UNDER_PUSH_MS = 4L * 3600 * 1000 + + fun schedule(context: Context, pushed: Boolean) { + val job = JobInfo.Builder(JOB_ID, ComponentName(context, PollJob::class.java)) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setPeriodic(if (pushed) UNDER_PUSH_MS else FETCHING_MS) + .setPersisted(true) + .build() + context.getSystemService(JobScheduler::class.java).schedule(job) + } + + fun cancel(context: Context) = context.getSystemService(JobScheduler::class.java).cancel(JOB_ID) +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt new file mode 100644 index 0000000..e8619a5 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt @@ -0,0 +1,76 @@ +package org.meshbay.client.notify + +import android.app.Activity +import android.app.NotificationManager +import android.os.Build +import org.json.JSONArray +import org.json.JSONObject +import org.unifiedpush.android.connector.UnifiedPush + +/** + * Notifications on this phone (§11.3), with nothing to install. + * + * Turned on, the phone fetches what is new every quarter of an hour (`PollJob`). + * If a UnifiedPush distributor is already on the phone — ntfy, or an application + * that carries one — it is used as well, and notifications arrive at once; if it + * refuses or goes away, the phone simply goes back to fetching. The page gives + * the hub whatever this side ends up with. Phone-only: the desktop has no + * counterpart, and its preload has no such channels. + * + * Whether a notification is wanted at all — every one turned off, or one group + * muted — is decided on the hub, which then creates nothing, so nothing is + * pushed or fetched. Nothing here second-guesses it. + */ +class PushChannels( + private val activity: Activity, + private val state: PushState, + private val channelNames: () -> Map<String, String>, +) { + fun handles(channel: String) = channel.startsWith("push:") + + fun call(channel: String, args: JSONArray): Any? = when (channel) { + "push:status" -> status() + "push:enable" -> enable() + "push:disable" -> { disable(); status() } + "push:remember" -> { + state.remember(args.optString(0, ""), args.optString(1, ""), args.optString(2, ""), args.optString(3, "")) + Poller.schedule(activity, pushed = state.endpoint != null) + status() + } + else -> throw org.meshbay.client.bridge.Refused("Refused: no such channel") + } + + private fun distributors() = UnifiedPush.getDistributors(activity).any { it != activity.packageName } + + private fun status(): JSONObject = state.status() + .put("distributors", distributors()) + .put("permitted", activity.getSystemService(NotificationManager::class.java).areNotificationsEnabled()) + + private fun enable(): JSONObject { + state.requested() + Notifier.ensureChannels(activity, channelNames()) + if (!distributors()) state.fellBack("NO_DISTRIBUTOR") + activity.runOnUiThread { + if (Build.VERSION.SDK_INT >= 33) { + activity.requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), PERMISSION_REQUEST) + } + // Only when one is installed: the system's chooser for a person who + // has none would be a screen about something they never asked for. + if (distributors()) UnifiedPush.tryUseDefaultDistributor(activity) { found -> + if (found) UnifiedPush.register(activity, messageForDistributor = "MeshBay") + else state.fellBack("NO_DISTRIBUTOR") + } + } + return status() + } + + private fun disable() { + Poller.cancel(activity) + try { UnifiedPush.removeDistributor(activity) } catch (e: Exception) { /* none was saved */ } + state.cleared() + } + + companion object { + private const val PERMISSION_REQUEST = 4207 + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt new file mode 100644 index 0000000..64ea7a7 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt @@ -0,0 +1,41 @@ +package org.meshbay.client.notify + +import android.content.Context +import android.util.Log +import org.meshbay.client.bridge.Bridge +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.PushService +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage + +/** + * What a distributor tells this application, page running or not — when the + * phone has one. Losing it is not an error: the phone goes back to fetching + * (`PollJob`), and the page tells the hub at its next start. + * + * A message is drawn only if the connector decrypted it with this phone's + * key: anything else did not come from a hub holding the subscription. + */ +class PushReceiver : PushService() { + private fun state() = PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE)) + + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { + state().endpoint(endpoint.url, endpoint.pubKeySet?.pubKey, endpoint.pubKeySet?.auth) + } + + override fun onMessage(message: PushMessage, instance: String) { + val state = state() + if (!message.decrypted || !state.enabled) { Log.w(Bridge.TAG, "push message dropped"); return } + Notifier.parse(message.content)?.let { Notifier.deliver(this, state, it) } + } + + override fun onRegistrationFailed(reason: FailedReason, instance: String) = fallBack(reason.name) + + override fun onUnregistered(instance: String) = fallBack("UNREGISTERED") + + private fun fallBack(reason: String) { + val state = state() + state.fellBack(reason) + if (state.enabled) Poller.schedule(this, pushed = false) + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt new file mode 100644 index 0000000..3905d58 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt @@ -0,0 +1,126 @@ +package org.meshbay.client.notify + +import android.content.SharedPreferences +import org.json.JSONObject + +/** + * Where this phone stands with the hub and, when it has one, with a push + * distributor — kept across restarts, because both answer whenever they like, + * often with the page not running. + * + * Turned on, it is always `ready` in the end: with an endpoint when a + * distributor gave one (pushed, instantly), without one otherwise (fetched, + * every quarter of an hour). Nothing to install is the default; a distributor + * is a bonus, and losing it falls back rather than failing. + * + * `registered` is the endpoint the hub was last given (null for none). When it + * differs from `endpoint` the page registers again; that is the whole of what + * keeps the hub's row current. + */ +class PushState(private val prefs: SharedPreferences) { + + fun status(): JSONObject = JSONObject() + .put("enabled", enabled) + .put("state", prefs.getString(STATE, OFF)) + .put("reason", prefs.getString(REASON, null) ?: JSONObject.NULL) + .put("endpoint", endpoint ?: JSONObject.NULL) + .put("p256dh", prefs.getString(P256DH, null) ?: JSONObject.NULL) + .put("auth", prefs.getString(AUTH, null) ?: JSONObject.NULL) + .put("subscription", prefs.getString(SUBSCRIPTION, null) ?: JSONObject.NULL) + .put("account", prefs.getString(ACCOUNT, null) ?: JSONObject.NULL) + .put("registered", prefs.getString(REGISTERED, null) ?: JSONObject.NULL) + + val enabled get() = prefs.getBoolean(ENABLED, false) + val endpoint: String? get() = prefs.getString(ENDPOINT, null) + + fun requested() = prefs.edit().putBoolean(ENABLED, true).putString(STATE, PENDING).remove(REASON).apply() + + fun endpoint(url: String, p256dh: String?, auth: String?) { + if (!enabled) return + // A distributor speaking only the old protocol gives no keys, and + // nothing could be encrypted to it: fetch instead. + if (p256dh == null || auth == null) { fellBack("NO_KEYS"); return } + prefs.edit().putString(STATE, READY).remove(REASON) + .putString(ENDPOINT, url).putString(P256DH, p256dh).putString(AUTH, auth).apply() + } + + /** No distributor, or it refused or dropped us: fetch, and say why. */ + fun fellBack(reason: String) { + if (!enabled) return + prefs.edit().putString(STATE, READY).putString(REASON, reason) + .remove(ENDPOINT).remove(P256DH).remove(AUTH).apply() + } + + /** What the hub answered a registration: its row, the account, the poll secret, its clock. */ + fun remember(subscription: String, account: String, secret: String, since: String) { + require(SUBSCRIPTION_ID.matches(subscription) && ACCOUNT_ID.matches(account) && + SECRET.matches(secret) && SINCE.matches(since)) { "bad subscription" } + prefs.edit().putString(SUBSCRIPTION, subscription).putString(ACCOUNT, account) + .putString(SECRET_KEY, secret).putString(SINCE_KEY, since) + .putString(REGISTERED, endpoint).apply() + } + + /** The hub no longer knows this row: the page registers again when it next runs. */ + fun forgetSubscription() = prefs.edit().remove(SUBSCRIPTION).remove(SECRET_KEY).remove(REGISTERED).apply() + + data class PollTarget(val id: String, val secret: String, val since: String) + + fun pollTarget(): PollTarget? { + if (!enabled) return null + return PollTarget(prefs.getString(SUBSCRIPTION, null) ?: return null, + prefs.getString(SECRET_KEY, null) ?: return null, + prefs.getString(SINCE_KEY, null) ?: return null) + } + + /** Fetch from here next time. ISO-8601 from the hub's own clock, so they compare as text. */ + fun advance(cursor: String) { + if (SINCE.matches(cursor) && cursor > (prefs.getString(SINCE_KEY, "") ?: "")) { + prefs.edit().putString(SINCE_KEY, cursor).apply() + } + } + + /** + * True the first time this line is seen at this date — pushed and then + * fetched, it is drawn once. A conversation keeps its id and moves its + * date, so a newer date is news again. + */ + @Synchronized + fun firstSight(id: Long, createdAt: String): Boolean { + val seen = try { JSONObject(prefs.getString(SEEN, "{}") ?: "{}") } catch (e: Exception) { JSONObject() } + val key = id.toString() + if (seen.optString(key, "") >= createdAt) return false + seen.put(key, createdAt) + if (seen.length() > SEEN_MAX) { + seen.keys().asSequence().toList().sortedBy { seen.optString(it) } + .take(seen.length() - SEEN_MAX).forEach { seen.remove(it) } + } + prefs.edit().putString(SEEN, seen.toString()).apply() + return true + } + + fun cleared() = prefs.edit().clear().apply() + + companion object { + const val OFF = "off" + const val PENDING = "pending" + const val READY = "ready" + private const val ENABLED = "enabled" + private const val STATE = "state" + private const val REASON = "reason" + private const val ENDPOINT = "endpoint" + private const val P256DH = "p256dh" + private const val AUTH = "auth" + private const val SUBSCRIPTION = "subscription" + private const val ACCOUNT = "account" + private const val REGISTERED = "registered" + private const val SECRET_KEY = "pollSecret" + private const val SINCE_KEY = "since" + private const val SEEN = "seen" + private const val SEEN_MAX = 100 + private val SUBSCRIPTION_ID = Regex("^[0-9a-f-]{36}$") + private val ACCOUNT_ID = Regex("^[0-9A-Za-z-]{1,64}$") + private val SECRET = Regex("^[A-Za-z0-9_-]{20,64}$") + private val SINCE = Regex("^\\d{4}-\\d\\d-\\d\\dT[0-9:.]+(\\+00:00|Z)$") + const val PREFS = "push" + } +} diff --git a/packages/meshbay-android/app/src/main/res/drawable/ic_notify.xml b/packages/meshbay-android/app/src/main/res/drawable/ic_notify.xml new file mode 100644 index 0000000..ec26359 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/drawable/ic_notify.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- The status-bar icon: the system keeps only its alpha, so a plain M. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FFFFFFFF" + android:pathData="M3,20V4h3l6,8 6,-8h3v16h-3V9l-6,8 -6,-8v11z" /> +</vector> diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt new file mode 100644 index 0000000..c575906 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt @@ -0,0 +1,108 @@ +package org.meshbay.client + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.meshbay.client.notify.Notifier +import org.meshbay.client.notify.PushState + +class PushTest { + private fun payload(vararg pairs: Pair<String, Any?>) = + JSONObject().apply { put("v", 1); pairs.forEach { (k, v) -> put(k, v ?: JSONObject.NULL) } } + .toString().toByteArray() + + @Test fun `a conversation is one entry per group, anything else one per notification`() { + val chat = Notifier.parse(payload("id" to 7, "kind" to "chat_message", "title" to "a posted in b", + "group_id" to "g-1", "link" to "#/group/g-1"))!! + assertEquals(Notifier.CHANNEL_CHAT, chat.channel) + assertEquals("chat:g-1", chat.tag) + assertEquals("#/group/g-1", chat.link) + val invite = Notifier.parse(payload("id" to 8, "kind" to "group_invite", "title" to "x invited you", + "group_id" to null, "link" to "#/"))!! + assertEquals(Notifier.CHANNEL_OTHER, invite.channel) + assertEquals("n:8", invite.tag) + } + + @Test fun `a link that is not a route inside the page is dropped, not followed`() { + for (bad in listOf("https://elsewhere.example/", "javascript:alert(1)", "#/x\";alert(1)//", "//host/#/")) { + val shown = Notifier.parse(payload("id" to 1, "kind" to "k", "title" to "t", "link" to bad))!! + assertNull(bad, shown.link) + } + } + + @Test fun `what is not ours to draw is not drawn`() { + assertNull(Notifier.parse("not json".toByteArray())) + assertNull(Notifier.parse(JSONObject().put("v", 2).put("title", "t").toString().toByteArray())) + assertNull(Notifier.parse(payload("id" to 1, "kind" to "k", "title" to " "))) + } + + private val sub = "0f8fad5b-d9cb-469f-a165-70867728950e" + private val account = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val secret = "Zm9vYmFyYmF6cXV4X3NlY3JldF92YWx1ZQ" + private val since = "2026-10-09T10:00:00.123456+00:00" + + @Test fun `the hub is registered again when the distributor hands out a new endpoint`() { + val state = PushState(FakePrefs()) + state.endpoint("https://push.example.net/1", "k", "a") // not asked for: ignored + assertEquals(PushState.OFF, state.status().getString("state")) + state.requested() + state.endpoint("https://push.example.net/1", "k", "a") + state.remember(sub, account, secret, since) + val s = state.status() + assertEquals(s.getString("endpoint"), s.getString("registered")) + state.endpoint("https://push.example.net/2", "k", "a") + val moved = state.status() + assertEquals("https://push.example.net/1", moved.getString("registered")) + assertEquals("https://push.example.net/2", moved.getString("endpoint")) + } + + @Test fun `no distributor, or one that gives no keys, means fetching and not failing`() { + val state = PushState(FakePrefs()) + state.requested() + state.endpoint("https://push.example.net/1", null, null) + val s = state.status() + assertEquals(PushState.READY, s.getString("state")) + assertEquals("NO_KEYS", s.getString("reason")) + assertTrue(s.isNull("endpoint")) + state.remember(sub, account, secret, since) + assertEquals(PushState.PollTarget(sub, secret, since), state.pollTarget()) + } + + @Test fun `the fetch cursor only moves forward`() { + val state = PushState(FakePrefs()) + state.requested() + state.remember(sub, account, secret, since) + state.advance("2026-10-09T09:00:00+00:00") + assertEquals(since, state.pollTarget()!!.since) + state.advance("2026-10-09T11:00:00+00:00") + assertEquals("2026-10-09T11:00:00+00:00", state.pollTarget()!!.since) + } + + @Test fun `a line pushed then fetched is drawn once, and a conversation moving on is news`() { + val state = PushState(FakePrefs()) + assertTrue(state.firstSight(7, "2026-10-09T10:00:00+00:00")) + assertFalse(state.firstSight(7, "2026-10-09T10:00:00+00:00")) + assertTrue(state.firstSight(7, "2026-10-09T10:05:00+00:00")) + assertFalse(state.firstSight(7, "2026-10-09T10:01:00+00:00")) + } + + @Test fun `a row the hub forgot stops the fetch until the page registers again`() { + val state = PushState(FakePrefs()) + state.requested() + state.remember(sub, account, secret, since) + state.forgetSubscription() + assertNull(state.pollTarget()) + } + + @Test fun `only ids are remembered`() { + val state = PushState(FakePrefs()) + assertThrows(IllegalArgumentException::class.java) { state.remember("../x", account, secret, since) } + assertThrows(IllegalArgumentException::class.java) { state.remember(sub, "", secret, since) } + assertThrows(IllegalArgumentException::class.java) { state.remember(sub, account, "short", since) } + assertThrows(IllegalArgumentException::class.java) { state.remember(sub, account, secret, "yesterday") } + } +} |