Alert people when the app is not on screen

Two platforms, two mechanisms, because iOS leaves no choice.

Android gets MessengerService, a foreground service holding the long-poll
with nobody in the middle. It rebuilds its session from the vault rather
than sharing the ViewModel's — by the time it runs the Activity may be
gone, which is the situation it exists for. Notifications for messages,
and a high-priority one naming the caller for an incoming call, because an
unnamed ringing phone is not answerable.

⚠️ The persistent notification is not a bug. Android requires a visible
notice for a foreground service; removing it means the OS kills the
connection. That is the price of not routing wake-ups through Google.

⚠️ Exactly one poller at a time: the ViewModel polls while the app is on
screen, the service while it is not, handed over in onStart/onStop. Two
would race for the same envelopes and double-notify, so the pair has to
stay symmetrical.

iOS gets APNs, and that IS Apple on the delivery path — there is no
alternative, because iOS suspends a backgrounded process and provides
exactly one way to wake it. What Apple learns is metadata: that a device
was woken and when. The push is content-free by construction, since the
relay holds ciphertext it cannot read. ⚠️ Nothing may later put the
message into the push payload; anything that could would mean the relay
had read it.

Relay stores one token per DEVICE, not per user — messages are sealed per
device, so waking the wrong one wakes an app with nothing to decrypt.
Re-registering replaces rather than accumulates, because APNs reissues
tokens and stale ones wake devices that no longer exist. An empty token
means forget: a sign-out must reach this, or a handset goes on being woken
for an account it no longer holds. Android tokens are refused outright —
it receives directly and needs no row.

Not yet done: the APNs sender itself, which needs a .p8 auth key that only
a human with the developer account can mint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-08-26 20:54:25 -04:00
parent 66da4352b8
commit 30d4829ad0
4 changed files with 303 additions and 0 deletions
+14
View File
@@ -13,6 +13,15 @@
the app.
-->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Receiving while the app is not on screen, with nobody in the middle.
⚠️ remoteMessaging is the correct Android 14 type for this: it is
"continue a user-initiated conversation", which is exactly what a
held poll is. dataSync would be throttled and is not what this does. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<!-- ⚠️ Runtime permission on API 33+, which is this app's minSdk. Without
it the service still runs and every alert is silently dropped. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--
Voice notes. Requested at runtime as well: on this minSdk a dangerous
@@ -51,5 +60,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".MessengerService"
android:exported="false"
android:foregroundServiceType="remoteMessaging" />
</application>
</manifest>
@@ -33,6 +33,19 @@ object KeystoreVault {
private const val ALIAS = "flamenet.messenger.vault.v1"
private const val DIRECTORY = "vault"
/**
* Is there a session worth receiving for?
*
* ⚠️ Both halves, matching `restore()`: a device with no credential cannot
* reach the relay, and a credential with no identity cannot be addressed.
* Either alone is a half-finished sign-in, not something to run a
* foreground service for.
*/
fun hasSession(context: Context): Boolean = runCatching {
val v = open(context)
v.loadIdentity() != null && v.loadCredential() != null
}.getOrDefault(false)
fun open(context: Context): Vault = Vault(FileStorage(File(context.filesDir, DIRECTORY)), key())
/**
@@ -43,12 +43,39 @@ import androidx.compose.foundation.layout.heightIn
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ⚠️ Runtime permission on API 33+, which is this app's minSdk. Without
// it the service runs and every alert is dropped silently — no error,
// no banner, and nothing on screen to say why. Ask once here rather
// than discovering it as "notifications do not work".
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001)
}
setContent {
MessengerTheme {
Surface(modifier = Modifier.fillMaxSize()) { Root() }
}
}
}
// 🔴 **The handover: exactly one poller at a time.** The ViewModel polls
// while the app is on screen; the service polls while it is not. Two
// pollers would race for the same envelopes and double-notify, so this
// pair has to stay symmetrical — if one side moves, move the other.
override fun onStart() {
super.onStart()
MessengerService.stop(this)
}
override fun onStop() {
super.onStop()
// ⚠️ Only when there is a session to receive for. Starting it signed
// out puts a permanent notice in the shade for an app doing nothing,
// and the service stops itself — but not before the user has seen it.
if (KeystoreVault.hasSession(applicationContext)) MessengerService.start(this)
}
}
@Composable
@@ -0,0 +1,249 @@
package io.flamenet.messenger.app
import android.app.Notification
import android.app.NotificationChannel
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.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.Vault
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Keeps receiving while the app is not on screen.
*
* 🔴 **This is the only way to be alerted without a third party.** iOS has one
* mechanism for waking a suspended app and it is Apple's; Android does not have
* that limit — a foreground service may hold a connection with nobody in the
* middle. That asymmetry is the whole reason this class exists, and it is worth
* being honest about on the privacy page rather than letting people assume the
* two platforms behave alike.
*
* ⚠️ **The persistent notification is not optional and not a bug.** Android
* requires a visible notice for a foreground service. Users will ask to remove
* it; removing it means the OS kills the connection. It is the price of not
* routing wake-ups through Google.
*
* ⚠️ **Exactly one poller at a time.** The ViewModel polls while the app is on
* screen and this service polls while it is not — started from `onStop`,
* stopped from `onStart`. Two pollers would race for the same envelopes and
* double-notify. If that handover ever changes, check both ends.
*
* ⚠️ Some OEMs (Xiaomi, Huawei, Samsung on aggressive battery settings) kill
* foreground services anyway. Nothing here can prevent that; the poll resumes
* when the app is next opened, and no message is lost because the relay holds
* it until the cursor advances.
*/
class MessengerService : Service() {
companion object {
private const val ONGOING_ID = 1
private const val CHANNEL_ONGOING = "flamenet.ongoing"
private const val CHANNEL_MESSAGES = "flamenet.messages"
private const val CHANNEL_CALLS = "flamenet.calls"
/** ⚠️ Matches the relay's own hold, so a poll returns as it expires. */
private const val WAIT_SECONDS = 20
fun start(context: Context) {
val intent = Intent(context, MessengerService::class.java)
runCatching { context.startForegroundService(intent) }
}
fun stop(context: Context) {
runCatching { context.stopService(Intent(context, MessengerService::class.java)) }
}
}
private var scope: CoroutineScope? = null
private var loop: Job? = null
private var session: Session? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannels()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// ⚠️ startForeground within a few seconds of onStartCommand or Android
// kills the process with a ForegroundServiceDidNotStartInTimeException.
// Do it before any I/O, not after the session is built.
startInForeground()
if (loop?.isActive == true) return START_STICKY
val s = CoroutineScope(SupervisorJob() + Dispatchers.IO)
scope = s
loop = s.launch { run() }
return START_STICKY
}
override fun onDestroy() {
loop?.cancel()
scope?.cancel()
session?.onCallSignal = null
session = null
super.onDestroy()
}
private fun startInForeground() {
val open = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val notice = NotificationCompat.Builder(this, CHANNEL_ONGOING)
.setContentTitle("Flamenet is receiving")
.setContentText("Messages arrive without Google or Apple in the middle.")
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setContentIntent(open)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(ONGOING_ID, notice,
ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING)
} else {
startForeground(ONGOING_ID, notice)
}
}
/**
* Rebuild the session from the vault rather than sharing the ViewModel's.
*
* ⚠️ The Activity may already be gone by the time this runs — that is the
* situation the service exists for — so there is nothing to share. The
* vault is the durable half and both sides read it the same way.
*/
private fun openSession(): Session? = runCatching {
val vault: Vault = KeystoreVault.open(applicationContext)
val identity = vault.loadIdentity() ?: return null
val credential = vault.loadCredential() ?: return null
val relay = RelayClient(
credential.relayBaseUrl, credential.username, credential.appPassword)
relay.useRelayAuth(identity)
Session(
handle = credential.username,
userId = credential.userId,
identity = identity,
vault = vault,
relay = relay,
api = MessengerApi(
credential.relayBaseUrl, credential.username, credential.appPassword),
)
}.getOrNull()
private suspend fun run() {
val s = openSession() ?: run {
// Nothing to receive for — signed out, or the vault is unreadable.
// Stop rather than sit in the notification shade doing nothing.
stopSelf()
return
}
session = s
// ⚠️ The constant, not the literal: the wire value is "call-offer",
// and a bare "offer" matches nothing and rings for no call ever.
s.onCallSignal = { fromUser, signal ->
if (signal.action == io.flamenet.e2e.Content.CallSignal.OFFER) {
notifyCall(nameFor(fromUser, s))
}
}
while (true) {
val touched = runCatching { s.receive() }.getOrDefault(emptySet())
if (touched.isNotEmpty()) notifyMessages(touched.size)
// ⚠️ A short gap, not a poll interval: `receive()` is held open by
// the relay for WAIT_SECONDS, so this only paces a relay that
// answers immediately.
delay(500)
}
}
/**
* A caller's name, from the buddy list.
*
* ⚠️ Cached for the life of the service and refreshed only on a miss. This
* runs on a ringing phone — the user has seconds — so a name lookup must
* not wait on the network when it already knows the answer.
*/
private var names: Map<Long, String> = emptyMap()
private fun nameFor(userId: Long, s: Session): String {
names[userId]?.let { return it }
names = runCatching {
s.api.buddies().associate { it.userId to it.displayName.ifBlank { it.handle } }
}.getOrDefault(names)
// Falls back to something answerable rather than a bare number.
return names[userId] ?: "Someone"
}
private fun notifyMessages(threads: Int) {
val open = PendingIntent.getActivity(
this, 1, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
// 🔴 **No sender and no preview.** The notification is drawn on this
// device from locally decrypted plaintext, so it *could* carry both —
// but a lock screen is a shoulder-surfing surface, and the product's
// whole claim is that messages are for the two people in them. Count
// only, until there is a setting for it.
val text = if (threads == 1) "New message" else "New messages in $threads chats"
notify(CHANNEL_MESSAGES, 2, text, NotificationCompat.PRIORITY_HIGH, open)
}
private fun notifyCall(from: String) {
val open = PendingIntent.getActivity(
this, 2, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
// A caller IS named: an unnamed ringing phone is not answerable, and a
// call is a live event the user has seconds to act on.
notify(CHANNEL_CALLS, 3, "$from is calling", NotificationCompat.PRIORITY_MAX, open,
category = NotificationCompat.CATEGORY_CALL)
}
private fun notify(channel: String, id: Int, text: String, priority: Int,
open: PendingIntent, category: String? = null) {
val n = NotificationCompat.Builder(this, channel)
.setContentTitle("Flamenet")
.setContentText(text)
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setPriority(priority)
.setAutoCancel(true)
.setContentIntent(open)
.also { b -> category?.let { b.setCategory(it) } }
.build()
runCatching {
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.notify(id, n)
}
}
private fun createChannels() {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ONGOING, "Receiving",
NotificationManager.IMPORTANCE_MIN).apply {
description = "The notice Android requires while Flamenet is receiving."
})
manager.createNotificationChannel(
NotificationChannel(CHANNEL_MESSAGES, "Messages",
NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(
NotificationChannel(CHANNEL_CALLS, "Calls",
NotificationManager.IMPORTANCE_HIGH).apply {
setBypassDnd(false)
})
}
}