The ring stops when the caller hangs up, and two things on screen

Only a call OFFER was marked time-critical, so only an offer got the VoIP
push that reaches a locked, suspended phone. Hanging up sent the end as
ordinary traffic, which a sleeping phone does not wake for, and it went on
ringing for a caller who had already gone. The end of a call is as
time-critical as its start. The rule moves to the companion so the test can
ask it what it decides rather than read how it is written.

The sign-in screen showed a letter in a box where the app has a mark of its
own. It now shows the artwork and the navy the launcher icon uses, so the
screen someone lands on matches the icon they tapped.

And setting up Files asked for the passphrase twice. A second box saying
"type it again" is how you confirm a secret somebody is inventing on that
screen; this is the passphrase they already sign in with, which the
paragraph above the box says in as many words. Asking twice only suggested
it was a different one. iOS asks once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-12 09:00:25 -04:00
co-authored by Claude Opus 5
parent 63a057a533
commit c031532dc3
4 changed files with 76 additions and 35 deletions
@@ -104,7 +104,6 @@ private fun ColumnScope.FilesSetUp(
files: MessengerViewModel.FilesState, files: MessengerViewModel.FilesState,
) { ) {
var passphrase by remember { mutableStateOf("") } var passphrase by remember { mutableStateOf("") }
var again by remember { mutableStateOf("") }
// "A passphrase of their own" told people to invent a second secret, and // "A passphrase of their own" told people to invent a second secret, and
// iOS never did. iOS says "your passphrase" and takes whatever is typed, so // iOS never did. iOS says "your passphrase" and takes whatever is typed, so
@@ -133,22 +132,15 @@ private fun ColumnScope.FilesSetUp(
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("files-new-passphrase"), modifier = Modifier.fillMaxWidth().testTag("files-new-passphrase"),
) )
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
value = again,
onValueChange = { again = it },
label = { Text("Type it again") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
// Confirmed rather than trusted.
isError = again.isNotEmpty() && again != passphrase,
modifier = Modifier.fillMaxWidth().testTag("files-new-passphrase-again"),
)
Spacer(Modifier.height(Space.md)) Spacer(Modifier.height(Space.md))
// Asked once, like iOS. A second box saying "type it again" is how you
// confirm a secret somebody is inventing here; this is the passphrase they
// already sign in with, so asking twice only suggested it was a different
// one, which is the opposite of what the paragraph above says.
KitPrimaryButton( KitPrimaryButton(
text = files.busy ?: "Set up Files", text = files.busy ?: "Set up Files",
onClick = { model.createFilesIdentity(passphrase) }, onClick = { model.createFilesIdentity(passphrase) },
enabled = files.busy == null && passphrase.isNotEmpty() && passphrase == again, enabled = files.busy == null && passphrase.isNotEmpty(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
tag = "files-create", tag = "files-create",
) )
@@ -5,6 +5,7 @@ import androidx.activity.viewModels
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -49,9 +50,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -63,6 +65,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import io.flamenet.messenger.R
import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen
/** /**
@@ -337,15 +340,22 @@ private fun ColumnScope.SignIn(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
// The app's own mark, not a letter standing in for it: the same
// artwork and navy the launcher icon uses, so the screen someone lands
// on matches the icon they tapped.
Box( Box(
modifier = modifier =
Modifier Modifier
.size(48.dp) .size(64.dp)
.clip(RoundedCornerShape(Radius.medium)) .clip(RoundedCornerShape(Radius.medium))
.background(MaterialTheme.colorScheme.primary), .background(colorResource(R.color.ic_launcher_background)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text("F", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White) Image(
painter = painterResource(R.mipmap.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
} }
Spacer(Modifier.height(Space.lg)) Spacer(Modifier.height(Space.lg))
Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold) Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold)
@@ -559,10 +559,7 @@ class Conversations(
* ordinary alert, which on iOS is a "New message" banner rather than the * ordinary alert, which on iOS is a "New message" banner rather than the
* ringing call screen, and a locked iPhone shows nothing at all. * ringing call screen, and a locked iPhone shows nothing at all.
*/ */
private fun ringsAPhone(body: String): Boolean { private fun ringsAPhone(body: String): Boolean = Companion.ringsAPhone(body)
if (!body.startsWith("""{"fnc":""")) return false
return Content.parse(body).call?.action == Content.CallSignal.OFFER
}
private fun sendPlaintext( private fun sendPlaintext(
toUserId: Long, toUserId: Long,
@@ -1532,4 +1529,22 @@ class Conversations(
* The session for a peer device: the stored one, or a new handshake. * The session for a peer device: the stored one, or a new handshake.
*/ */
private fun sessionFor(bundle: PeerBundle): E2ESession = existingSession(bundle.deviceId) ?: E2ESession.initiate(identity, bundle) private fun sessionFor(bundle: PeerBundle): E2ESession = existingSession(bundle.deviceId) ?: E2ESession.initiate(identity, bundle)
companion object {
/**
* Whether this envelope has to reach a locked, suspended phone now
* (spec §6.14). Held out here so it can be tested for what it decides
* rather than for how it is written.
*
* The end of a call is as time-critical as its start. Only the offer
* was marked, so hanging up before the other phone was picked up sent
* the end as ordinary traffic: a backgrounded phone never woke for it
* and went on ringing for a caller who had already gone.
*/
fun ringsAPhone(body: String): Boolean {
if (!body.startsWith("""{"fnc":""")) return false
val action = Content.parse(body).call?.action
return action == Content.CallSignal.OFFER || action == Content.CallSignal.END
}
}
} }
@@ -1,16 +1,17 @@
package io.flamenet.client package io.flamenet.client
import io.flamenet.e2e.Content
import java.io.File import java.io.File
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
/** /**
* A call offer must be marked time-critical (spec §6.14). * Call signalling must be marked time-critical (spec §6.14).
* *
* The relay holds ciphertext, so it cannot tell an offer from a birthday * The relay holds ciphertext, so it cannot tell an offer from a birthday
* message. It sends a VoIP push only when the sender says urgent, and only a * message. It sends a VoIP push only when the sender says urgent, and only a
* VoIP push rings a locked, suspended iPhone. Android set `silent` and never * VoIP push reaches a locked, suspended iPhone. Android set `silent` and never
* `urgent`, so an Android call reached an iPhone as an ordinary "New message" * `urgent`, so an Android call reached an iPhone as an ordinary "New message"
* notification and the call screen never appeared. * notification and the call screen never appeared.
*/ */
@@ -24,6 +25,11 @@ class UrgentCallOfferTest {
private val conversations = source("src/main/kotlin/io/flamenet/client/Conversations.kt") private val conversations = source("src/main/kotlin/io/flamenet/client/Conversations.kt")
private val relayClient = source("src/main/kotlin/io/flamenet/client/RelayClient.kt") private val relayClient = source("src/main/kotlin/io/flamenet/client/RelayClient.kt")
private fun signal(
action: String,
callId: String = "c1",
) = Content.callSignal(Content.CallSignal(action, callId))
/** The flag reaches the wire. */ /** The flag reaches the wire. */
@Test @Test
fun urgentIsSentToTheRelay() { fun urgentIsSentToTheRelay() {
@@ -34,24 +40,42 @@ class UrgentCallOfferTest {
) )
} }
/** And a call offer sets it. */ /** And the send path asks. */
@Test @Test
fun aCallOfferIsUrgent() { fun theSendPathAsks() {
assertTrue(conversations.contains("private fun ringsAPhone(body: String): Boolean"))
assertTrue( assertTrue(
conversations.contains("urgent = ringsAPhone(text),"), conversations.contains("urgent = ringsAPhone(text),"),
"the send path must pass it", "the send path must pass it",
) )
assertTrue( }
conversations.contains("Content.parse(body).call?.action == Content.CallSignal.OFFER"),
"only an offer rings", /** An offer rings. */
) @Test
fun aCallOfferIsUrgent() {
assertTrue(Conversations.ringsAPhone(signal(Content.CallSignal.OFFER)))
} }
/** /**
* Urgent and silent answer different questions. An offer is both loud and * And so does the end of one. A call that is over has to stop the ring on
* time-critical; an answer or a hangup is neither, and must not ring a * a phone that is asleep, which takes the same push the offer took: this
* phone for a call that is already over. * is why hanging up first left the other phone ringing to nobody.
*/
@Test
fun hangingUpIsUrgentToo() {
assertTrue(Conversations.ringsAPhone(signal(Content.CallSignal.END)))
}
/** Nothing else does. */
@Test
fun theRestOfTheCallDoesNotRing() {
assertFalse(Conversations.ringsAPhone(signal(Content.CallSignal.ANSWER)))
assertFalse(Conversations.ringsAPhone(signal(Content.CallSignal.REACTION)))
}
/**
* Urgent and silent answer different questions. Waking a phone is not the
* same as making a noise on it, and deriving one from the other would ring
* a phone for every message.
*/ */
@Test @Test
fun theTwoFlagsStaySeparate() { fun theTwoFlagsStaySeparate() {
@@ -67,7 +91,7 @@ class UrgentCallOfferTest {
/** A body that is not an fnc envelope is an ordinary message, and never rings. */ /** A body that is not an fnc envelope is an ordinary message, and never rings. */
@Test @Test
fun aPlainMessageNeverRings() { fun aPlainMessageNeverRings() {
val fn = conversations.substringAfter("private fun ringsAPhone").substringBefore("}") assertFalse(Conversations.ringsAPhone("happy birthday"))
assertTrue(fn.contains("return false"), "must refuse a non-envelope body") assertFalse(Conversations.ringsAPhone(""))
} }
} }