Notification settings, which Android never had #112
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user