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:
co-authored by
Claude Opus 4.8
parent
53ba8ea42e
commit
2a5970309e
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user