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,
) {
var passphrase by remember { mutableStateOf("") }
var again by remember { mutableStateOf("") }
// "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
@@ -133,22 +132,15 @@ private fun ColumnScope.FilesSetUp(
singleLine = true,
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))
// 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(
text = files.busy ?: "Set up Files",
onClick = { model.createFilesIdentity(passphrase) },
enabled = files.busy == null && passphrase.isNotEmpty() && passphrase == again,
enabled = files.busy == null && passphrase.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
tag = "files-create",
)
@@ -5,6 +5,7 @@ import androidx.activity.viewModels
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -49,9 +50,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
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.semantics
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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import io.flamenet.messenger.R
import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen
/**
@@ -337,15 +340,22 @@ private fun ColumnScope.SignIn(
modifier = Modifier.fillMaxWidth(),
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(
modifier =
Modifier
.size(48.dp)
.size(64.dp)
.clip(RoundedCornerShape(Radius.medium))
.background(MaterialTheme.colorScheme.primary),
.background(colorResource(R.color.ic_launcher_background)),
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))
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
* ringing call screen, and a locked iPhone shows nothing at all.
*/
private fun ringsAPhone(body: String): Boolean {
if (!body.startsWith("""{"fnc":""")) return false
return Content.parse(body).call?.action == Content.CallSignal.OFFER
}
private fun ringsAPhone(body: String): Boolean = Companion.ringsAPhone(body)
private fun sendPlaintext(
toUserId: Long,
@@ -1532,4 +1529,22 @@ class Conversations(
* 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)
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
import io.flamenet.e2e.Content
import java.io.File
import kotlin.test.Test
import kotlin.test.assertFalse
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
* 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"
* 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 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. */
@Test
fun urgentIsSentToTheRelay() {
@@ -34,24 +40,42 @@ class UrgentCallOfferTest {
)
}
/** And a call offer sets it. */
/** And the send path asks. */
@Test
fun aCallOfferIsUrgent() {
assertTrue(conversations.contains("private fun ringsAPhone(body: String): Boolean"))
fun theSendPathAsks() {
assertTrue(
conversations.contains("urgent = ringsAPhone(text),"),
"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
* time-critical; an answer or a hangup is neither, and must not ring a
* phone for a call that is already over.
* And so does the end of one. A call that is over has to stop the ring on
* a phone that is asleep, which takes the same push the offer took: this
* 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
fun theTwoFlagsStaySeparate() {
@@ -67,7 +91,7 @@ class UrgentCallOfferTest {
/** A body that is not an fnc envelope is an ordinary message, and never rings. */
@Test
fun aPlainMessageNeverRings() {
val fn = conversations.substringAfter("private fun ringsAPhone").substringBefore("}")
assertTrue(fn.contains("return false"), "must refuse a non-envelope body")
assertFalse(Conversations.ringsAPhone("happy birthday"))
assertFalse(Conversations.ringsAPhone(""))
}
}