diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt index 313cd97..1f383a8 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt @@ -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", ) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index 5d6eb5a..42df542 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -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) diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index d555d23..2fd319e 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -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 + } + } } diff --git a/client/src/test/kotlin/io/flamenet/client/UrgentCallOfferTest.kt b/client/src/test/kotlin/io/flamenet/client/UrgentCallOfferTest.kt index 6318423..6e7d1fe 100644 --- a/client/src/test/kotlin/io/flamenet/client/UrgentCallOfferTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/UrgentCallOfferTest.kt @@ -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("")) } }