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:
co-authored by
Claude Opus 5
parent
63a057a533
commit
c031532dc3
@@ -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(""))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user