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:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user