Notification settings, which Android never had #112

Merged
paulhitt merged 1 commits from feat/android-notification-settings into main 2026-09-07 01:14:44 +00:00
5 changed files with 287 additions and 11 deletions
@@ -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) }
@@ -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:<id>`, 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)
}
@@ -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,
)
}
@@ -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"
}
}
@@ -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))
}
}