diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 95d8999..ee736d3 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -804,6 +804,9 @@ fun ColumnScope.AccountScreen( // A bridge is about REACHING the relay on a hostile network, not hiding // from it — filing it as privacy would make the claim the section itself // is careful not to make. + SectionBand() + SettingsSection { NotificationSection() } + SectionBand() SettingsSection { NetworkSection(model, phase.buddies) } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt index fa635e5..a63ee15 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt @@ -230,16 +230,24 @@ class MessengerService : Service() { return names[userId] ?: "Someone" } - private fun notifyMessages(threads: Int) { + private fun notifyMessages(threads: Int, from: String? = null) { + val settings = NotificationSettings(this) + // 🔴 The master switch and the per-kind one, both checked before a + // notification is built rather than after. A notification suppressed + // late still woke the screen. + if (!settings.enabled || !settings.newMessages) return + 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" + // 🔴 **Still no sender and no preview unless it was asked for.** The + // notification is drawn on this device from locally decrypted + // plaintext, so it *could* always have carried both — but a lock screen + // is a shoulder-surfing surface, and the product's claim is that a + // message is for the two people in it. `NotificationSettings` decides; + // its default is the count-only behaviour that was here before, so + // nobody acquires a name on their lock screen by upgrading. + val text = settings.messageText(threads, from) notify(CHANNEL_MESSAGES, 2, text, NotificationCompat.PRIORITY_HIGH, open, number = threads) } @@ -257,10 +265,19 @@ class MessengerService : Service() { val open = PendingIntent.getActivity( this, 4, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) - // Same privacy rule as a message notification: a count, never a name. - // Who you are waiting on is as sensitive as what they said. - val text = if (due.size == 1) "Still no reply" - else "Still no reply in ${due.size} chats" + // Same privacy rule as a message notification, and the same setting: + // who you are waiting on is as sensitive as what they said. + val settings = NotificationSettings(this) + if (!settings.enabled || !settings.newMessages) return + // ⚠️ Only a one-to-one thread can be named. `threadForUser` writes + // `user:`, and groups and cross-relay peers use their own prefixes + // — so anything else falls through to the count, which is the safe + // direction: a wrong name on a lock screen is worse than no name. + val only = due.keys.singleOrNull() + ?.removePrefix("user:")?.takeIf { it.all(Char::isDigit) } + ?.toLongOrNull() + ?.let { userId -> runCatching { nameFor(userId, s) }.getOrNull() } + val text = settings.replyReminderText(due.size, only) notify(CHANNEL_MESSAGES, 5, text, NotificationCompat.PRIORITY_DEFAULT, open) } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSection.kt b/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSection.kt new file mode 100644 index 0000000..4e67b70 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSection.kt @@ -0,0 +1,84 @@ +package io.flamenet.messenger.app + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.sp + +/** + * What this phone's notifications are allowed to say. + * + * 🔴 **Android had none of this.** iOS has offered these since it shipped, and + * the parity scrub on 2026-09-06 found eight preferences on one phone and not + * the other. `MessengerService` had already written down what was missing, at + * the only place it could: "Count only, until there is a setting for it." + */ +@Composable +fun ColumnScope.NotificationSection() { + val context = LocalContext.current + val settings = remember { NotificationSettings(context) } + var enabled by remember { mutableStateOf(settings.enabled) } + var newMessages by remember { mutableStateOf(settings.newMessages) } + var previews by remember { mutableStateOf(settings.showPreviews) } + var sounds by remember { mutableStateOf(settings.inAppSounds) } + + Text("Notifications", style = MaterialTheme.typography.titleMedium) + + @Composable + fun row(label: String, tag: String, value: Boolean, onChange: (Boolean) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, fontSize = 16.sp, modifier = Modifier.weight(1f)) + Switch(checked = value, onCheckedChange = onChange, + modifier = Modifier.testTag(tag)) + } + } + + row("Notify me", "notify-enabled", enabled) { on -> + settings.enabled = on; enabled = on + } + if (enabled) { + row("New messages", "notify-messages", newMessages) { on -> + settings.newMessages = on; newMessages = on + } + row("Show who it is from", "notify-previews", previews) { on -> + settings.showPreviews = on; previews = on + } + row("Sound while the app is open", "notify-sounds", sounds) { on -> + settings.inAppSounds = on; sounds = on + } + } + + Spacer(Modifier.height(Space.xs)) + + // 🔴 Says what turning it on costs, and does not pretend the default is + // merely cautious. Who is writing to you is, on some phones, the more + // sensitive of the two facts. + Text( + if (previews) + "Your lock screen will name whoever wrote to you. Anyone who can see your " + + "phone can see that — which is sometimes more revealing than the message." + else + "Notifications say how many messages arrived and nothing else — no name, no " + + "words. Calls still ring.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSettings.kt b/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSettings.kt new file mode 100644 index 0000000..3734c72 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSettings.kt @@ -0,0 +1,103 @@ +package io.flamenet.messenger.app + +import android.content.Context + +/** + * What a notification is allowed to say, and whether to post one at all. + * + * 🔴 **A lock screen is a shoulder-surfing surface.** This app draws its + * notifications on the phone, from plaintext it has already decrypted, so it + * *could* put the sender and the message on the lock screen — and for most of + * this app's life it deliberately did not, because the product's whole claim is + * that a message is for the two people in it. `MessengerService` says so at the + * only place it could have: "Count only, until there is a setting for it." + * + * This is that setting, and it defaults to the behaviour that was there before: + * a count, no name, no words. Somebody who wants their phone to say who is + * writing can ask for it; nobody gets it by upgrading. + * + * ⚠️ Same keys and same defaults as iOS, so the two phones behave alike for one + * person. iOS reads these through `SettingsKeys`; the names differ because the + * platforms store differently, and the DEFAULTS are what must not drift. + */ +class NotificationSettings(context: Context) { + + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + /** The master switch. Off means no notification of any kind is posted. */ + var enabled: Boolean + get() = prefs.getBoolean(ENABLED, true) + set(value) = prefs.edit().putBoolean(ENABLED, value).apply() + + /** Notify about arriving messages. Calls ring regardless — see [enabled]. */ + var newMessages: Boolean + get() = prefs.getBoolean(NEW_MESSAGES, true) + set(value) = prefs.edit().putBoolean(NEW_MESSAGES, value).apply() + + /** + * Put the sender's name, and a hint of the message, on the lock screen. + * + * 🔴 **Off by default, and that is the honest default rather than the + * cautious one.** Turning it on is a real choice with a real cost: anyone + * who can see the phone can see who is writing to you, which on some + * phones is a more sensitive fact than what they said. The people for whom + * that is fine can have it; nobody should acquire it by installing an + * update. + */ + var showPreviews: Boolean + get() = prefs.getBoolean(SHOW_PREVIEWS, false) + set(value) = prefs.edit().putBoolean(SHOW_PREVIEWS, value).apply() + + /** A sound when a notification is posted while the app is open. */ + var inAppSounds: Boolean + get() = prefs.getBoolean(IN_APP_SOUNDS, true) + set(value) = prefs.edit().putBoolean(IN_APP_SOUNDS, value).apply() + + /** + * The text for a message notification, given how many threads are waiting. + * + * 🔴 **Lifted out of the service so it can be asserted.** A string built + * inline inside a `NotificationCompat.Builder` chain is a string nothing + * can test, and the thing being decided here is whether a name reaches a + * lock screen. `registeredLine` records what that costs — a template that + * shipped rendering its own source code, invisible to the compiler. + * + * ⚠️ `from` is only consulted when previews are on. A caller that passes a + * name is not asking for it to be shown; this decides that. + */ + fun messageText(threads: Int, from: String?): String = + messageText(showPreviews, threads, from) + + /** The same question for a reply reminder. */ + fun replyReminderText(count: Int, from: String?): String = + replyReminderText(showPreviews, count, from) + + companion object { + /** + * 🔴 **Pure, and takes the preference as an argument, so a test can + * call THIS rather than a copy of it.** The first version of the test + * beside this file reimplemented the rules against a fake and passed + * perfectly while asserting nothing about the code that ships — the + * same shape as two identically-wrong engines agreeing. A `Context` is + * not available in a JVM unit test, so the decision had to leave the + * class that needs one. + */ + fun messageText(showPreviews: Boolean, threads: Int, from: String?): String = when { + showPreviews && threads == 1 && !from.isNullOrBlank() -> "New message from $from" + threads == 1 -> "New message" + else -> "New messages in $threads chats" + } + + fun replyReminderText(showPreviews: Boolean, count: Int, from: String?): String = when { + showPreviews && count == 1 && !from.isNullOrBlank() -> "Still no reply from $from" + count == 1 -> "Still no reply" + else -> "Still no reply in $count chats" + } + + private const val PREFS = "notification-settings" + private const val ENABLED = "enabled" + private const val NEW_MESSAGES = "newMessages" + private const val SHOW_PREVIEWS = "showPreviews" + private const val IN_APP_SOUNDS = "inAppSounds" + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/NotificationTextTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/NotificationTextTest.kt new file mode 100644 index 0000000..1c4e4d4 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/NotificationTextTest.kt @@ -0,0 +1,69 @@ +package io.flamenet.messenger.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * What a notification is allowed to say. + * + * 🔴 **The default is the load-bearing part.** Previews off means a lock screen + * says how many messages arrived and nothing else — no name, no words. That was + * the behaviour before this setting existed, and nobody may acquire a name on + * their lock screen by installing an update. + * + * ⚠️ The text is built by `NotificationSettings` rather than inline in the + * `NotificationCompat.Builder` chain precisely so it can be asserted here. A + * string built inside that chain is a string nothing can test, and what is + * being decided is whether somebody's name reaches a lock screen. + */ +class NotificationTextTest { + + // 🔴 Calls the SHIPPING functions. The first version of this file + // reimplemented the rules against a fake and passed perfectly while + // asserting nothing about the code that runs — two identically-wrong ends + // agreeing, which is the shape this codebase has been bitten by before. + private fun messageText(previews: Boolean, threads: Int, from: String?) = + NotificationSettings.messageText(previews, threads, from) + + private fun replyText(previews: Boolean, count: Int, from: String?) = + NotificationSettings.replyReminderText(previews, count, from) + + @Test + fun `with previews off no name reaches the lock screen`() { + assertEquals("New message", messageText(false, 1, "Alice")) + assertEquals("New messages in 3 chats", messageText(false, 3, "Alice")) + assertEquals("Still no reply", replyText(false, 1, "Alice")) + assertFalse(messageText(false, 1, "Alice").contains("Alice")) + } + + @Test + fun `with previews on the sender is named`() { + assertEquals("New message from Alice", messageText(true, 1, "Alice")) + assertEquals("Still no reply from Alice", replyText(true, 1, "Alice")) + } + + /** + * ⚠️ Several threads means no name even with previews on. Naming one of + * three would be arbitrary, and naming all of them turns a notification + * into a contact list on the lock screen. + */ + @Test + fun `several threads are counted, never named`() { + assertEquals("New messages in 2 chats", messageText(true, 2, "Alice")) + assertEquals("Still no reply in 4 chats", replyText(true, 4, "Alice")) + } + + /** + * 🔴 A caller that has no name must not produce "New message from null" or + * a dangling "from". The service cannot always resolve one — a group, a + * cross-relay peer, or a contact it has never seen. + */ + @Test + fun `a missing name falls back to the count rather than showing a hole`() { + assertEquals("New message", messageText(true, 1, null)) + assertEquals("New message", messageText(true, 1, "")) + assertEquals("New message", messageText(true, 1, " ")) + assertEquals("Still no reply", replyText(true, 1, null)) + } +}