Badges, video stories, and parental controls

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-28 21:36:43 -04:00
co-authored by Claude Opus 4.8
parent 53ba8ea42e
commit 2a5970309e
12 changed files with 558 additions and 49 deletions
@@ -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
@@ -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,
@@ -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<String?>(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)
}
}
/**
@@ -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 {
@@ -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 {
@@ -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) }
}
@@ -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"))
}
}
@@ -690,8 +690,10 @@ class Session(
height: Int,
caption: String?,
toUserIds: List<Long>,
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(
@@ -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<ByteArray?>(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) {
@@ -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<File?>(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 }
}
},
)
}
@@ -225,12 +225,14 @@ class Conversations(
caption: String?,
toUserIds: List<Long>,
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)
@@ -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