From 2a5970309e384e2c2c7ae70b240f6d2f8abdc449 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 28 Aug 2026 21:36:43 -0400 Subject: [PATCH] Badges, video stories, and parental controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launcher badges on the message notification (the service notice explicitly never badges). Stories accept picked videos β€” validated against a 30s/9MB cap rather than transcoded, said honestly when refused β€” and play looping in the card via the framework player. The wire's dur field travels for any attachment that has one. Parental controls mirror iOS: a parent's PIN guards adding contacts, stories, and calls; incoming calls answer busy; sign-up gains an on-device age gate that stores nothing. The server learns nothing. Co-Authored-By: Claude Opus 4.8 --- .../io/flamenet/messenger/app/CallManager.kt | 13 ++ .../io/flamenet/messenger/app/ChatScreens.kt | 54 +++++-- .../io/flamenet/messenger/app/MainActivity.kt | 51 +++++- .../messenger/app/MessengerService.kt | 15 +- .../messenger/app/MessengerViewModel.kt | 24 ++- .../messenger/app/ParentalControls.kt | 79 ++++++++++ .../messenger/app/ParentalControlsSection.kt | 145 +++++++++++++++++ .../io/flamenet/messenger/app/Session.kt | 4 +- .../io/flamenet/messenger/app/StoriesFeed.kt | 69 +++++--- .../io/flamenet/messenger/app/StoryMedia.kt | 148 ++++++++++++++++++ .../io/flamenet/client/Conversations.kt | 2 + .../main/kotlin/io/flamenet/e2e/Content.kt | 3 +- 12 files changed, 558 insertions(+), 49 deletions(-) create mode 100644 app/src/main/kotlin/io/flamenet/messenger/app/ParentalControls.kt create mode 100644 app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt create mode 100644 app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt index 4e66e81..d812bac 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt @@ -307,6 +307,10 @@ class CallManager( /** Place a call. The offer is gathered, then sent as one encrypted envelope. */ fun call(peerId: Long, turn: JSONObject?, video: Boolean = false) { + // Supervision: enforced where the call would start, not where the + // button is drawn β€” every entry point hits the same wall. + // Backstop only β€” the view model refuses first, with the message. + if (ParentalControls.callsDisabled(context)) return if (state != State.IDLE && state != State.ENDED) return val callId = UUID.randomUUID().toString() // πŸ”΄ `current` BEFORE the state change. The state setter notifies the @@ -358,6 +362,15 @@ class CallManager( "call signal in: ${incoming.action} from $fromUser state=$state") when (incoming.action) { Content.CallSignal.OFFER -> { + // Supervision: a device with calls off does not ring β€” the + // caller is answered busy, like any other unavailable state. + if (ParentalControls.callsDisabled(context)) { + runCatching { + signal(fromUser, Content.CallSignal( + Content.CallSignal.END, incoming.callId, reason = "busy")) + } + return + } // πŸ”΄ An offer older than the caller's own patience is not a // call. The caller gives up after DIAL_TIMEOUT_MS; one that // aged in the mailbox while this app was closed would ring for 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 b1ea2f8..a39411d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -663,6 +663,9 @@ fun ColumnScope.AccountScreen( SectionBand() SettingsSection { HistoryBackupSection(model) } + SectionBand() + SettingsSection { ParentalControlsSection() } + SectionBand() SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) } @@ -1980,17 +1983,7 @@ fun ColumnScope.StoryStrip( ActivityResultContracts.GetContent() ) { uri -> uri ?: return@rememberLauncherForActivityResult - val bytes = runCatching { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - }.getOrNull() ?: return@rememberLauncherForActivityResult - val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } - android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) - model.stageStory( - bytes, - context.contentResolver.getType(uri) ?: "image/jpeg", - bounds.outWidth, - bounds.outHeight, - ) + StoryMedia.stage(context, uri, model) } LaunchedEffect(Unit) { model.refreshStories() } @@ -2019,7 +2012,7 @@ fun ColumnScope.StoryStrip( .size(Kit.avatar) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { pickStory.launch("image/*") }, + .clickable { pickStory.launch("*/*") }, contentAlignment = Alignment.Center, ) { Icon( @@ -2322,6 +2315,43 @@ fun ColumnScope.AddContactForm( onDone: () -> Unit, ) { var username by remember(initial) { mutableStateOf(initial) } + // Supervision: adding someone is the guarded act. The form only renders + // once the parent's PIN has been entered, and the unlock lasts for this + // form's life, not the app's. + val gateContext = androidx.compose.ui.platform.LocalContext.current + var supervisionUnlocked by remember { mutableStateOf(false) } + if (ParentalControls.contactsGated(gateContext) && !supervisionUnlocked) { + var gatePin by remember { mutableStateOf("") } + var gateWrong by remember { mutableStateOf(false) } + Text("Supervision is on β€” a parent's PIN is needed to add a contact.", + fontSize = 12.sp, color = Palette.SecondaryText) + Spacer(Modifier.height(Space.xs)) + OutlinedTextField( + value = gatePin, onValueChange = { gatePin = it; gateWrong = false }, + placeholder = { Text("Parent PIN", fontSize = 14.sp) }, + singleLine = true, + visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), + isError = gateWrong, + modifier = Modifier.fillMaxWidth().testTag("contact-gate-pin"), + ) + if (gateWrong) { + Text("That's not the PIN.", fontSize = 12.sp, + color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.height(Space.xs)) + KitPrimaryButton( + text = "Unlock", + onClick = { + if (ParentalControls.verify(gateContext, gatePin)) supervisionUnlocked = true + else gateWrong = true + gatePin = "" + }, + enabled = gatePin.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + tag = "contact-gate-unlock", + ) + return + } OutlinedTextField( value = username, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index 74e51d9..7263abd 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -303,6 +303,8 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo var passphrase by remember { mutableStateOf("") } var email by remember { mutableStateOf("") } var registering by remember { mutableStateOf(false) } + var birthYear by remember { mutableStateOf("") } + var ageError by remember { mutableStateOf(null) } // Only a relay keeping its own accounts needs an address for reset mail. val needsEmail = registering && site?.accountProvider == SiteConfig.AccountProvider.LOCAL @@ -404,6 +406,29 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo modifier = Modifier.fillMaxWidth().testTag("passphrase"), ) + if (registering) { + // The age gate. Neutral, and stated honestly: the year is checked on + // this device to refuse an under-13 sign-up, then forgotten. Nothing + // about age is sent to any server. + OutlinedTextField( + value = birthYear, + onValueChange = { birthYear = it.filter(Char::isDigit).take(4) }, + label = { Text("Year of birth") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), + modifier = Modifier.fillMaxWidth().testTag("birth-year"), + ) + Text( + "Checked on this phone to confirm you're old enough, then forgotten β€” never sent anywhere.", + style = MaterialTheme.typography.bodySmall, + ) + ageError?.let { + Text(it, color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("age-error")) + } + } + phase.error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error")) } @@ -415,10 +440,23 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Button( onClick = { + if (registering) { + // The most conservative reading β€” a birthday not yet + // passed this year β€” because "maybe old enough" isn't. + val year = birthYear.toIntOrNull() ?: 0 + val age = java.util.Calendar.getInstance().get(java.util.Calendar.YEAR) - year + if (year < 1900 || age < 14) { + ageError = if (year < 1900) "Enter your year of birth." + else "You must be at least 13 to use Flamenet Messenger." + return@Button + } + ageError = null + } model.signIn(handle.trim(), passphrase, register = registering, email = email.trim()) }, enabled = !phase.busy && handle.isNotBlank() && passphrase.isNotBlank() && - (!needsEmail || email.isNotBlank()), + (!needsEmail || email.isNotBlank()) && + (!registering || birthYear.length == 4), shape = RoundedCornerShape(KitButton.radius), modifier = Modifier.width(240.dp).height(KitButton.height).testTag("submit"), ) { @@ -537,8 +575,11 @@ private fun BottomNav( // every missed call in the log for ever, a badge nothing could // clear. Opening the tab is what "seeing" them means. model.missedCallsUnseen(phase), "nav-calls") - NavButton(model, phase, MessengerViewModel.Phase.Screen.STORIES, - KitIcon.Stories, KitIcon.Stories, "Stories", 0, "nav-stories") + // Supervision removes the surface rather than emptying it. + if (!ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)) { + NavButton(model, phase, MessengerViewModel.Phase.Screen.STORIES, + KitIcon.Stories, KitIcon.Stories, "Stories", 0, "nav-stories") + } // Alerts left the bar 2026-08-28 β€” five tabs plus it was crowded, and // iOS never had one (its unread count badges Account). Same here now; // the screen itself survives for the badge tap-through from Account. @@ -727,7 +768,9 @@ internal fun ColumnScope.SignedIn( HorizontalDivider(color = Palette.SecondaryText.copy(alpha = 0.16f)) - StoriesCards(model, phase) + if (!ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)) { + StoriesCards(model, phase) + } } /** 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 2b5d66e..3f6fbea 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt @@ -218,7 +218,8 @@ class MessengerService : Service() { // 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) + notify(CHANNEL_MESSAGES, 2, text, NotificationCompat.PRIORITY_HIGH, open, + number = threads) } private fun notifyCall(from: String) { @@ -232,7 +233,7 @@ class MessengerService : Service() { } private fun notify(channel: String, id: Int, text: String, priority: Int, - open: PendingIntent, category: String? = null) { + open: PendingIntent, category: String? = null, number: Int = 0) { val n = NotificationCompat.Builder(this, channel) .setContentTitle("Flamenet") .setContentText(text) @@ -241,6 +242,11 @@ class MessengerService : Service() { .setAutoCancel(true) .setContentIntent(open) .also { b -> category?.let { b.setCategory(it) } } + // The launcher badge. A dot on every launcher; the count itself on + // launchers that show one. Same privacy rule as the text: a number, + // never a name or a preview. + .also { b -> if (number > 0) b.setNumber(number) } + .setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL) .build() runCatching { (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) @@ -254,10 +260,13 @@ class MessengerService : Service() { NotificationChannel(CHANNEL_ONGOING, "Receiving", NotificationManager.IMPORTANCE_MIN).apply { description = "The notice Android requires while Flamenet is receiving." + // The always-on service notice must not put a dot on the icon: + // a permanent badge reads as "you have unread mail", for ever. + setShowBadge(false) }) manager.createNotificationChannel( NotificationChannel(CHANNEL_MESSAGES, "Messages", - NotificationManager.IMPORTANCE_HIGH)) + NotificationManager.IMPORTANCE_HIGH).apply { setShowBadge(true) }) manager.createNotificationChannel( NotificationChannel(CHANNEL_CALLS, "Calls", NotificationManager.IMPORTANCE_HIGH).apply { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index efe4222..8d995da 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -1143,6 +1143,11 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } private fun placeCall(peerId: Long, video: Boolean = false) = viewModelScope.launch { + // Supervision: refused with the reason, before anything is gathered. + if (ParentalControls.callsDisabled(getApplication())) { + update { it.copy(error = "Calls are turned off on this device.") } + return@launch + } callWasConnected = false val current = session ?: return@launch val manager = callManager() @@ -1311,13 +1316,19 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val mime: String, val width: Int, val height: Int, + /** Seconds; non-zero exactly when this is a video. */ + val duration: Double = 0.0, ) - /** Hold a picked image for the composer sheet rather than posting it. */ - fun stageStory(bytes: ByteArray, mime: String, width: Int, height: Int) { - update { it.copy(storyDraft = StoryDraft(bytes, mime, width, height)) } + /** Hold picked media for the composer sheet rather than posting it. */ + fun stageStory(bytes: ByteArray, mime: String, width: Int, height: Int, + duration: Double = 0.0) { + update { it.copy(storyDraft = StoryDraft(bytes, mime, width, height, duration)) } } + /** A story pick that cannot be staged, said in the same place other errors are. */ + fun reportStoryProblem(message: String) = update { it.copy(error = message) } + fun discardStagedStory() = update { it.copy(storyDraft = null) } /** The deliberate Post from the composer sheet. */ @@ -1325,12 +1336,13 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val draft = signedIn()?.storyDraft ?: return update { it.copy(storyDraft = null) } postStory(draft.bytes, draft.mime, draft.width, draft.height, - caption?.takeIf { it.isNotBlank() }, audience) + caption?.takeIf { it.isNotBlank() }, audience, draft.duration) } fun postStory(bytes: ByteArray, mime: String, width: Int, height: Int, caption: String?, /** THIS story's audience from the composer; null = the account setting. */ - audienceOverride: Session.StoryAudience? = null) = + audienceOverride: Session.StoryAudience? = null, + duration: Double = 0.0) = viewModelScope.launch { val current = session ?: return@launch val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty() @@ -1350,7 +1362,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } update { it.copy(busy = true, error = null) } val done = withContext(Dispatchers.IO) { - runCatching { current.postStory(bytes, mime, width, height, caption, recipients) } + runCatching { current.postStory(bytes, mime, width, height, caption, recipients, duration) } } done.onSuccess { posted -> update { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControls.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControls.kt new file mode 100644 index 0000000..bfec448 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControls.kt @@ -0,0 +1,79 @@ +package io.flamenet.messenger.app + +import android.content.Context +import java.security.MessageDigest +import java.security.SecureRandom + +/** + * Device-side supervision a parent configures on a child's phone. + * + * The honest shape for an E2E messenger: the server cannot read messages, so + * there is nothing server-side to moderate β€” what a parent CAN govern is what + * this device is allowed to do. A PIN the parent sets guards three switches: + * adding new contacts, the stories surface, and calls. + * + * **Threat model, stated plainly:** this restrains a child using a phone the + * parent configured. It is enforced in this app's own UI and stored on the + * device; it is not a defence against someone who can install apps freely or + * clear this one's data β€” that boundary belongs to Family Link, which parents + * should use alongside it. + * + * The PIN is stored as a salted SHA-256 hash, never in the clear, and none of + * this β€” the PIN, the toggles, or the fact supervision is on β€” ever leaves + * the device. The server learns nothing. + */ +object ParentalControls { + + private const val PREFS = "parental-controls" + private const val PIN_HASH = "pinHash" + private const val PIN_SALT = "pinSalt" + const val CONTACTS_GATED = "contactsGated" + const val STORIES_HIDDEN = "storiesHidden" + const val CALLS_DISABLED = "callsDisabled" + + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + fun isEnabled(context: Context) = prefs(context).getString(PIN_HASH, null) != null + + fun contactsGated(context: Context) = + isEnabled(context) && prefs(context).getBoolean(CONTACTS_GATED, false) + + fun storiesHidden(context: Context) = + isEnabled(context) && prefs(context).getBoolean(STORIES_HIDDEN, false) + + fun callsDisabled(context: Context) = + isEnabled(context) && prefs(context).getBoolean(CALLS_DISABLED, false) + + fun setGate(context: Context, key: String, on: Boolean) { + prefs(context).edit().putBoolean(key, on).apply() + } + + fun enable(context: Context, pin: String) { + val salt = ByteArray(16).also(SecureRandom()::nextBytes) + prefs(context).edit() + .putString(PIN_SALT, salt.joinToString("") { "%02x".format(it) }) + .putString(PIN_HASH, hash(pin, salt)) + // The gates default ON β€” a parent turning this on wants + // supervision, and each is individually releasable afterwards. + .putBoolean(CONTACTS_GATED, true) + .putBoolean(STORIES_HIDDEN, true) + .putBoolean(CALLS_DISABLED, true) + .apply() + } + + fun verify(context: Context, pin: String): Boolean { + val stored = prefs(context).getString(PIN_HASH, null) ?: return false + val saltHex = prefs(context).getString(PIN_SALT, null) ?: return false + val salt = saltHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + return hash(pin, salt) == stored + } + + fun disable(context: Context) = prefs(context).edit().clear().apply() + + private fun hash(pin: String, salt: ByteArray): String = + MessageDigest.getInstance("SHA-256") + .apply { update(salt) } + .digest(pin.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt new file mode 100644 index 0000000..28afbeb --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt @@ -0,0 +1,145 @@ +package io.flamenet.messenger.app + +import androidx.compose.foundation.layout.Column +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.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.sp + +/** + * Parental controls on the Account screen: a parent's PIN guarding what this + * device may do. Opening the switches requires the PIN β€” they would be + * decoration if the person they restrain could flip them. + */ +@Composable +fun ParentalControlsSection() { + val context = LocalContext.current + // Bumped on enable/disable/toggle so the section re-reads preferences. + var tick by remember { mutableIntStateOf(0) } + var unlocked by remember { mutableStateOf(false) } + var pinEntry by remember { mutableStateOf("") } + var wrong by remember { mutableStateOf(false) } + var newPin by remember { mutableStateOf("") } + var repeatPin by remember { mutableStateOf("") } + + @Suppress("UNUSED_EXPRESSION") tick + + Text("Parental controls", style = MaterialTheme.typography.titleMedium) + + when { + !ParentalControls.isEnabled(context) -> { + Text( + "A parent sets a PIN on this phone. While supervision is on, " + + "adding contacts asks for the PIN, and stories and calls can " + + "be switched off β€” all enforced on this device. Nothing about " + + "supervision is sent to any server. Use it alongside Family " + + "Link, which governs the phone itself.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + Spacer(Modifier.height(Space.sm)) + OutlinedTextField( + value = newPin, onValueChange = { newPin = it }, + placeholder = { Text("Parent PIN (at least 4 digits)", fontSize = 14.sp) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth().testTag("parental-pin"), + ) + Spacer(Modifier.height(Space.xs)) + OutlinedTextField( + value = repeatPin, onValueChange = { repeatPin = it }, + placeholder = { Text("Repeat PIN", fontSize = 14.sp) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth().testTag("parental-pin-repeat"), + ) + Spacer(Modifier.height(Space.sm)) + OutlinedButton( + onClick = { + ParentalControls.enable(context, newPin) + newPin = ""; repeatPin = ""; unlocked = true; tick++ + }, + enabled = newPin.length >= 4 && newPin == repeatPin, + modifier = Modifier.testTag("parental-enable"), + ) { Text("Turn on supervision") } + } + + !unlocked -> { + Text("Supervision is on. The settings behind this are the parent's to change.", + fontSize = 12.sp, color = Palette.SecondaryText) + Spacer(Modifier.height(Space.sm)) + OutlinedTextField( + value = pinEntry, onValueChange = { pinEntry = it; wrong = false }, + placeholder = { Text("Parent PIN", fontSize = 14.sp) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + isError = wrong, + modifier = Modifier.fillMaxWidth().testTag("parental-unlock-pin"), + ) + if (wrong) { + Text("That's not the PIN.", fontSize = 12.sp, + color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.height(Space.xs)) + OutlinedButton( + onClick = { + if (ParentalControls.verify(context, pinEntry)) unlocked = true else wrong = true + pinEntry = "" + }, + enabled = pinEntry.isNotEmpty(), + modifier = Modifier.testTag("parental-unlock"), + ) { Text("Unlock") } + } + + else -> Column { + GateRow("Ask the PIN to add a contact", ParentalControls.CONTACTS_GATED) { tick++ } + GateRow("Hide stories", ParentalControls.STORIES_HIDDEN) { tick++ } + GateRow("Turn off calls", ParentalControls.CALLS_DISABLED) { tick++ } + Text( + "With calls off, this phone neither places calls nor rings β€” a " + + "caller is answered busy.", + fontSize = 12.sp, color = Palette.SecondaryText, + ) + Spacer(Modifier.height(Space.sm)) + OutlinedButton( + onClick = { ParentalControls.disable(context); unlocked = false; tick++ }, + modifier = Modifier.testTag("parental-disable"), + ) { Text("Turn off supervision") } + } + } +} + +@Composable +private fun GateRow(label: String, key: String, onChanged: () -> Unit) { + val context = LocalContext.current + var on by remember(key) { + mutableStateOf( + context.getSharedPreferences("parental-controls", android.content.Context.MODE_PRIVATE) + .getBoolean(key, false)) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text(label, fontSize = 14.sp, modifier = Modifier.weight(1f)) + Switch(checked = on, onCheckedChange = { + on = it + ParentalControls.setGate(context, key, it) + onChanged() + }, modifier = Modifier.testTag("gate-$key")) + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 6b3070b..57b7333 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -690,8 +690,10 @@ class Session( height: Int, caption: String?, toUserIds: List, + duration: Double = 0.0, ): io.flamenet.client.Conversations.StoryPosted { - val posted = conversations.postStory(bytes, mime, width, height, caption, toUserIds) + val posted = conversations.postStory(bytes, mime, width, height, caption, toUserIds, + duration = duration) if (posted.reached > 0) { val now = System.currentTimeMillis() / 1000.0 vault.saveStories( diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index ab58d07..a9bf0d5 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -62,17 +62,7 @@ fun ColumnScope.StoriesFeed( androidx.activity.result.contract.ActivityResultContracts.GetContent() ) { uri -> uri ?: return@rememberLauncherForActivityResult - val bytes = runCatching { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - }.getOrNull() ?: return@rememberLauncherForActivityResult - val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } - android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) - model.stageStory( - bytes, - context.contentResolver.getType(uri) ?: "image/jpeg", - bounds.outWidth, - bounds.outHeight, - ) + StoryMedia.stage(context, uri, model) } Row( @@ -90,7 +80,7 @@ fun ColumnScope.StoriesFeed( tint = Palette.Accent, modifier = Modifier .size(28.dp) - .clickable { pickStory.launch("image/*") } + .clickable { pickStory.launch("*/*") } .testTag("new-story"), ) } @@ -181,7 +171,11 @@ private fun StoryCard( // πŸ”΄ The card's own image, cropped to FILL the box β€” not the chat // bubble's 260dp thumbnail, which floated small on a full-width // backboard and read as a broken layout. - StoryImage(story.ref, model) + if (story.ref.mime.startsWith("video/")) { + StoryVideoCard(story.ref, model) + } else { + StoryImage(story.ref, model) + } } if (story.caption.isNotBlank()) { @@ -210,10 +204,14 @@ fun StoryComposerDialog( val chosen = remember(draft) { androidx.compose.runtime.mutableStateOf(phase.storyAudience.userIds) } + val context = androidx.compose.ui.platform.LocalContext.current val image = remember(draft) { runCatching { - android.graphics.BitmapFactory.decodeByteArray(draft.bytes, 0, draft.bytes.size) - ?.asImageBitmap() + if (draft.duration > 0) + StoryMedia.posterFrame(draft.bytes, context)?.asImageBitmap() + else + android.graphics.BitmapFactory.decodeByteArray(draft.bytes, 0, draft.bytes.size) + ?.asImageBitmap() }.getOrNull() } androidx.compose.material3.AlertDialog( @@ -222,13 +220,19 @@ fun StoryComposerDialog( text = { Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) { image?.let { - androidx.compose.foundation.Image( - bitmap = it, - contentDescription = "Story preview", - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)), - ) + androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) { + androidx.compose.foundation.Image( + bitmap = it, + contentDescription = "Story preview", + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)), + ) + if (draft.duration > 0) { + Text("β–Ά ${draft.duration.toInt()}s", fontSize = 22.sp, + color = androidx.compose.ui.graphics.Color.White) + } + } } androidx.compose.material3.OutlinedTextField( value = caption.value, @@ -357,6 +361,27 @@ fun ColumnScope.StoriesCards( * `AttachedImage` deliberately caps at 260dp and letterboxes β€” right for a * bubble, wrong for the kit's full-width post card. */ +/** A story's video, fetched, decrypted, and looping in the card. */ +@Composable +private fun StoryVideoCard(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) { + val bytes = remember(ref.id) { + androidx.compose.runtime.mutableStateOf(null) + } + val failed = remember(ref.id) { androidx.compose.runtime.mutableStateOf(false) } + LaunchedEffect(ref.id) { + val fetched = model.attachmentBytes(ref) + if (fetched == null) failed.value = true else bytes.value = fetched + } + val ready = bytes.value + when { + ready != null -> StoryVideoPlayer(ready, cacheKey = ref.id, + modifier = Modifier.fillMaxSize()) + failed.value -> Text("Video unavailable", fontSize = 14.sp, + color = Palette.SecondaryText) + else -> Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText) + } +} + @Composable private fun StoryImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) { val bitmap = remember(ref.id) { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt new file mode 100644 index 0000000..67bafaa --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt @@ -0,0 +1,148 @@ +package io.flamenet.messenger.app + +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.widget.VideoView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import java.io.File + +/** + * Staging a picked story, image or video, from either entry point. + * + * Videos are validated, not transcoded: this platform has no built-in + * exporter the way AVFoundation is one, and pulling in a transcoding stack + * is a decision for later. So the caps are enforced honestly β€” a clip too + * long or too heavy is refused with the reason, and a clip that fits goes + * out exactly as picked. + */ +object StoryMedia { + + /** A story is a moment, not a film β€” same cap as iOS. */ + const val MAX_DURATION_S = 30 + /** Under the relay's 10 MB attachment cap, with room for the seal. */ + const val MAX_BYTES = 9_500_000 + + /** + * Read the pick, validate it, and stage it on the model. + * Reports refusals through the model's own error surface. + */ + fun stage(context: Context, uri: Uri, model: MessengerViewModel) { + val mime = context.contentResolver.getType(uri) ?: "image/jpeg" + + if (mime.startsWith("video/")) { + val retriever = MediaMetadataRetriever() + val meta = runCatching { + retriever.setDataSource(context, uri) + val durationMs = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + ?.toLongOrNull() ?: 0L + var w = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) + ?.toIntOrNull() ?: 0 + var h = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) + ?.toIntOrNull() ?: 0 + val rotation = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) + ?.toIntOrNull() ?: 0 + // A portrait phone clip is stored landscape plus a rotation. + if (rotation == 90 || rotation == 270) { val t = w; w = h; h = t } + Triple(durationMs / 1000.0, w, h) + }.getOrNull().also { runCatching { retriever.release() } } + + if (meta == null) { + model.reportStoryProblem("That video couldn't be read.") + return + } + val (seconds, w, h) = meta + if (seconds > MAX_DURATION_S + 0.5) { + model.reportStoryProblem( + "Stories are capped at $MAX_DURATION_S seconds β€” this one is " + + "${seconds.toInt()}. Trim it and try again.") + return + } + val bytes = runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() + if (bytes == null) { + model.reportStoryProblem("That video couldn't be read.") + return + } + if (bytes.size > MAX_BYTES) { + model.reportStoryProblem( + "That video is too large to send β€” the cap is about 9 MB. " + + "A shorter or lower-resolution clip will fit.") + return + } + model.stageStory(bytes, mime, w, h, duration = seconds) + return + } + + val bytes = runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() + if (bytes == null) { + model.reportStoryProblem("That file couldn't be read.") + return + } + val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } + android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + model.stageStory(bytes, mime, bounds.outWidth, bounds.outHeight) + } + + /** The first frame, for the composer's preview of a staged video. */ + fun posterFrame(bytes: ByteArray, context: Context): android.graphics.Bitmap? { + val f = File.createTempFile("story-poster", ".mp4", context.cacheDir) + return try { + f.writeBytes(bytes) + val retriever = MediaMetadataRetriever() + retriever.setDataSource(f.path) + retriever.getFrameAtTime(0).also { runCatching { retriever.release() } } + } catch (_: Exception) { + null + } finally { + f.delete() + } + } +} + +/** + * Plays a decrypted story video, looping, with sound. + * + * Framework `VideoView` on purpose β€” playback of one local file needs no + * player library. The decrypted bytes land in the app's cache dir, which is + * the same exposure as a decoded story bitmap in memory plus the OS's own + * cache lifetime. + */ +@Composable +fun StoryVideoPlayer(bytes: ByteArray, cacheKey: String, modifier: Modifier = Modifier) { + val context = androidx.compose.ui.platform.LocalContext.current + val file = remember(cacheKey) { mutableStateOf(null) } + LaunchedEffect(cacheKey) { + file.value = runCatching { + val f = File.createTempFile("story-$cacheKey", ".mp4", context.cacheDir) + f.writeBytes(bytes) + f + }.getOrNull() + } + val ready = file.value ?: return + AndroidView( + modifier = modifier, + factory = { ctx -> + VideoView(ctx).apply { + setVideoPath(ready.path) + setOnPreparedListener { player -> + player.isLooping = true + start() + } + setOnErrorListener { _, _, _ -> true } + } + }, + ) +} diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index e3c6995..6da7c89 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -225,12 +225,14 @@ class Conversations( caption: String?, toUserIds: List, lifetimeSeconds: Double = STORY_LIFETIME_SECONDS, + duration: Double = 0.0, ): StoryPosted { val sealed = Attachment.seal(data) val id = relay.uploadAttachment(sealed.blob) val ref = Attachment.Ref( kind = Attachment.STORY, id = id, key = sealed.key, digest = sealed.digest, bytes = sealed.blob.size, mime = mime, width = width, height = height, + duration = duration, expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds, ) val body = Content.attachment(ref, caption, null) diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 1e1ba4e..215f292 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -395,7 +395,8 @@ object Attachment { .put("mime", mime) .put("w", width) .put("h", height) - .also { if (kind == AUDIO) it.put("dur", duration) } + // Audio always; a story carries one when it is a video. + .also { if (duration > 0.0) it.put("dur", duration) } // πŸ”΄ Without this the vault round-trip erased every expiry: // saveStories persists refs through here, loadStories reads "exp" // back out of this very JSON, and a field never written reloads as