diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 46d45c6..d98d8b8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -184,7 +184,7 @@ dependencies { // module's classpath would make `:client` unable to change its own // dependencies without breaking a screen that never mentioned it. implementation(project(":obfs4")) - // πŸ”΄ BitmapFactory ignores EXIF orientation, so a photo from a phone + // BitmapFactory ignores EXIF orientation, so a photo from a phone // camera decodes on its side. This reads the tag so the rotation can be // applied before re-encoding drops it. See ImagePrep.orientedFrom. implementation("androidx.exifinterface:exifinterface:1.3.7") diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/AppLock.kt b/app/src/main/kotlin/io/flamenet/messenger/app/AppLock.kt index 5dac920..40a2df8 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/AppLock.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/AppLock.kt @@ -208,7 +208,8 @@ class AppLock( ) { return } - // The platform's own lockout is not counted on top of ours. + // The platform's own lockout is not counted on top of + // ours. onFailure(message.toString()) } }, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt index c0b4883..4d059e0 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt @@ -189,13 +189,13 @@ class CallManager( ): PeerConnection? { val config = PeerConnection.RTCConfiguration(iceServers(turn)).apply { - // ALL rather than RELAY: direct paths are tried, and TURN is the - // fallback. + // ALL rather than RELAY: direct paths are tried, and TURN is + // the fallback. iceTransportsType = PeerConnection.IceTransportsType.ALL sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN - // Trickle is off: candidates are gathered before the offer is sent, - // so signalling is ONE encrypted envelope each way rather than a - // stream of them. + // Trickle is off: candidates are gathered before the offer is + // sent, so signalling is ONE encrypted envelope each way rather + // than a stream of them. continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE } val observer = @@ -235,9 +235,9 @@ class CallManager( override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) { android.util.Log.i("fnmsg-call", "ice gathering -> $newState") - // Hop to the main thread rather than touching the connection - // from here, same JNI rule as above: no WebRTC calls, and no - // exceptions, from inside a WebRTC callback. + // Hop to the main thread rather than touching the + // connection from here, same JNI rule as above: no WebRTC + // calls, and no exceptions, from inside a WebRTC callback. if (newState == PeerConnection.IceGatheringState.COMPLETE) { main.post { runCatching { deliverGatheredSdp() } } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt index 955acab..4238363 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt @@ -392,8 +392,9 @@ fun rememberVideoCall(model: MessengerViewModel): (Long) -> Unit { ) { granted -> val peer = pending.value pending.value = null - // Denied still calls, as voice-grade video (their picture, no ours is - // wrong; the manager simply has no camera to add, so it degrades). + // Denied still calls, as voice-grade video (their picture, no ours + // is wrong; the manager simply has no camera to add, so it + // degrades). if (peer != null && granted) model.requestCall(peer, video = true) } return { peer -> diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 69e1e9c..4fd0d40 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -188,8 +188,8 @@ fun ColumnScope.BuddyList( Modifier .fillMaxWidth() .clickable { model.openChat(buddy.userId, buddy.displayName) } - // Half the kit's 24dp row gap on each side, which makes the - // 72dp pitch it draws between 48dp rows. + // Half the kit's 24dp row gap on each side, which makes + // the 72dp pitch it draws between 48dp rows. .padding(vertical = Kit.rowGap / 2) .testTag("buddy-${buddy.userId}"), verticalAlignment = Alignment.CenterVertically, @@ -2312,9 +2312,9 @@ private fun Bubble( modifier = Modifier .fillMaxWidth() - // The kit insets a received bubble by 40 from the leading edge, the - // 32dp avatar plus its gutter, and lets a sent one run to the - // margin. + // The kit insets a received bubble by 40 from the leading edge, + // the 32dp avatar plus its gutter, and lets a sent one run to + // the margin. .padding( start = if (line.mine) Kit.receivedInset else 0.dp, end = if (line.mine) 0.dp else Kit.receivedInset, @@ -2389,8 +2389,9 @@ private fun Bubble( Text("Not delivered", fontSize = 10.sp, color = Palette.Pink) } - // DELIVERED and null both draw nothing: a message that arrived - // needs no decoration, and null is every received message. + // DELIVERED and null both draw nothing: a message that + // arrived needs no decoration, and null is every received + // message. else -> { Unit } @@ -2441,8 +2442,8 @@ private fun Bubble( VoiceBubble(ref, model) } - // A document, not a picture: AttachedImage would download a - // PDF and draw nothing. + // A document, not a picture: AttachedImage would + // download a PDF and draw nothing. io.flamenet.e2e.Attachment.FILE -> { FileBubble( ref, @@ -2464,9 +2465,10 @@ private fun Bubble( if (line.text.isNotEmpty()) { Text( line.text, - // 16sp regular, per the kit's Body Text 2, which is what - // `messageTextSize` returns with larger type off, so this - // renders exactly as it always did until somebody asks. + // 16sp regular, per the kit's Body Text 2, which is + // what `messageTextSize` returns with larger type off, + // so this renders exactly as it always did until + // somebody asks. fontSize = bodySize.sp, color = if (line.mine) { @@ -2732,8 +2734,8 @@ fun ColumnScope.StoryStrip( .size(Kit.avatar) .clip(CircleShape) // Unseen is marked rather than styled away: a strip - // where read and unread look alike is a strip nobody - // comes back to. + // where read and unread look alike is a strip + // nobody comes back to. .background( if (story.seen) { Palette.AvatarFill @@ -3199,9 +3201,9 @@ fun ColumnScope.AddContactForm( ) { Text(found.displayName.ifBlank { found.handle }) Text("@${found.handle}", style = MaterialTheme.typography.bodySmall) - // Named plainly: identity is checked by comparing safety - // numbers once the conversation exists, and a lookup result is - // not a substitute for that. + // Named plainly: identity is checked by comparing safety numbers + // once the conversation exists, and a lookup result is not a + // substitute for that. Text( "Check their safety number after you start talking, this only says the " + "handle exists on this server.", diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt index a871375..9eacc53 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt @@ -104,51 +104,61 @@ object ImagePrep { /** * Turn the pixels the way the EXIF tag says, then forget the tag. - * - * πŸ”΄ **`BitmapFactory` does not apply EXIF orientation, and re-encoding - * throws the tag away.** A phone camera almost never writes upright - * pixels: it writes landscape pixels plus "rotate this 90Β°". Decoding - * ignores that, the JPEG we write out carries no EXIF at all β€” deliberately, - * because that block holds the GPS coordinates the shot was taken at β€” and - * so the only thing that knew which way was up is gone. Every portrait - * photo arrived on its side. - * - * ⚠️ **iOS is not affected and that is why this survived**, so a fix - * confirmed on one app proves nothing about the other: `UIImage(data:)` - * applies the orientation as it decodes, so the same strip-and-re-encode is - * safe there. Same design, opposite platform default. - * - * ⚠️ Returns the bitmap unchanged when there is nothing to do β€” the common - * case, and it avoids copying a large bitmap for no reason. */ - internal fun orientedFrom(source: ByteArray, bitmap: Bitmap): Bitmap { - val tag = runCatching { - ExifInterface(ByteArrayInputStream(source)) - .getAttributeInt(ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL) - }.getOrDefault(ExifInterface.ORIENTATION_NORMAL) + internal fun orientedFrom( + source: ByteArray, + bitmap: Bitmap, + ): Bitmap { + val tag = + runCatching { + ExifInterface(ByteArrayInputStream(source)) + .getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + }.getOrDefault(ExifInterface.ORIENTATION_NORMAL) val matrix = Matrix() when (tag) { - ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) - ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) - ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) - // ⚠️ The mirrored tags are rare but real β€” a front camera writes - // them. Handled rather than ignored, because a selfie coming back - // flipped is the same class of bug as one coming back sideways. - ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f) - ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f) + ExifInterface.ORIENTATION_ROTATE_90 -> { + matrix.postRotate(90f) + } + + ExifInterface.ORIENTATION_ROTATE_180 -> { + matrix.postRotate(180f) + } + + ExifInterface.ORIENTATION_ROTATE_270 -> { + matrix.postRotate(270f) + } + + // The mirrored tags are rare but real, a front camera writes them. + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> { + matrix.postScale(-1f, 1f) + } + + ExifInterface.ORIENTATION_FLIP_VERTICAL -> { + matrix.postScale(1f, -1f) + } + ExifInterface.ORIENTATION_TRANSPOSE -> { - matrix.postRotate(90f); matrix.postScale(-1f, 1f) + matrix.postRotate(90f) + matrix.postScale(-1f, 1f) } + ExifInterface.ORIENTATION_TRANSVERSE -> { - matrix.postRotate(270f); matrix.postScale(-1f, 1f) + matrix.postRotate(270f) + matrix.postScale(-1f, 1f) + } + + else -> { + return bitmap } - else -> return bitmap } - val turned = runCatching { - Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) - }.getOrNull() ?: return bitmap + val turned = + runCatching { + Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + }.getOrNull() ?: return bitmap if (turned !== bitmap) bitmap.recycle() return turned } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KeystoreVault.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KeystoreVault.kt index 643cefa..c1f045c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KeystoreVault.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KeystoreVault.kt @@ -47,8 +47,8 @@ object KeystoreVault { .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) // The vault supplies its own nonce per record, and a - // repeated nonce under one key is the catastrophic AES-GCM - // failure. + // repeated nonce under one key is the catastrophic AES- + // GCM failure. .setRandomizedEncryptionRequired(false) .build(), ) 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 5c86a94..7afe9aa 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -13,10 +13,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -26,7 +26,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider @@ -62,47 +61,28 @@ 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.app.MessengerViewModel.Phase.Screen /** - * ⚠️ `FragmentActivity`, not `ComponentActivity`, and that is not cosmetic: + * `FragmentActivity`, not `ComponentActivity`, and that is not cosmetic. * `BiometricPrompt` hosts a fragment, so on a plain `ComponentActivity` it - * silently cannot show β€” the callback never fires and the lock screen sits - * there looking broken. `FragmentActivity` extends `ComponentActivity`, so - * `setContent` and everything else is unaffected. + * silently cannot show, the callback never fires and the lock screen sits there + * looking broken. */ class MainActivity : androidx.fragment.app.FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // πŸ”΄ **Screenshots off, and out of the recents thumbnail.** - // - // The second half is the one that matters and the one nobody thinks - // about: without FLAG_SECURE, Android captures whatever is on screen - // into the recent-apps preview and WRITES IT TO DISK. An open - // conversation therefore exists as a plaintext bitmap outside every - // protection this app has β€” the ratchet, the sealed history file, the - // ThisDeviceOnly key β€” none of which cover a screenshot the OS took of - // the rendered thread. - // - // ⚠️ iOS has had the equivalent since the app lock landed: it obscures - // the switcher snapshot on `scenePhase`. Android had nothing at all. - // Two platforms disagreeing about a security guarantee is how one of - // them ends up being the one that leaks. - // - // ⚠️ It also blocks screen recording and casting, which is the point, - // and is not something to make optional: a setting here is a setting - // people leave off and then get surprised by. + // Screenshots off, and out of the recents thumbnail. window.setFlags( android.view.WindowManager.LayoutParams.FLAG_SECURE, android.view.WindowManager.LayoutParams.FLAG_SECURE, ) - // ⚠️ Runtime permission on API 33+, which is this app's minSdk. Without - // it the service runs and every alert is dropped silently β€” no error, - // no banner, and nothing on screen to say why. Ask once here rather - // than discovering it as "notifications do not work". + // Runtime permission on API 33+, which is this app's minSdk. if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) - != android.content.pm.PackageManager.PERMISSION_GRANTED) { + != android.content.pm.PackageManager.PERMISSION_GRANTED + ) { requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001) } @@ -110,25 +90,10 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { setContent { MessengerTheme { - // πŸ”΄ **`android:windowSoftInputMode="adjustResize"` stopped doing - // anything at targetSdk 35.** Android 15 forces apps that target - // it edge-to-edge, and an edge-to-edge window does not resize - // for the keyboard β€” so the manifest line has been a no-op on - // every device this ships to (minSdk 33), and the keyboard sat - // over the chat and the box being typed into. Reported as - // "bringing the chat above the keyboard would help me see what - // I'm typing", which is exactly the symptom. - // - // ⚠️ At the root rather than per screen: every screen with a - // text field has this problem, and one that is added later - // would have it too. `imePadding` is inset consumption, so the - // content shrinks and a scrolled list keeps its position β€” - // adding padding under the keyboard, not scrolling over it. + // `android:windowSoftInputMode="adjustResize"` stopped doing + // anything at targetSdk 35. Surface(modifier = Modifier.fillMaxSize().imePadding()) { - // πŸ”΄ **The gate is around Root, not inside it.** Drawn over - // the top, the message list still renders underneath for a - // frame β€” and a frame is all a screenshot or a shoulder - // needs. Locked means not composed. + // The gate is around Root, not inside it. var locked by remember { mutableStateOf(lock.enabled) } if (locked) { @@ -136,18 +101,24 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { onUnlock = { lock.authenticate( activity = this@MainActivity, - onSuccess = { lock.noteUnlocked(); locked = false }, + onSuccess = { + lock.noteUnlocked() + locked = false + }, onFailure = { /* the screen stays; it says why */ }, ) }, ) - // ⚠️ Asked once on arrival too, so the first thing after + // Asked once on arrival too, so the first thing after // launch is the prompt rather than a wall somebody has // to tap to discover. LaunchedEffect(Unit) { lock.authenticate( activity = this@MainActivity, - onSuccess = { lock.noteUnlocked(); locked = false }, + onSuccess = { + lock.noteUnlocked() + locked = false + }, onFailure = {}, ) } @@ -157,14 +128,22 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { // Re-arm on the way out, subject to the grace period. DisposableEffect(Unit) { - val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> - when (event) { - androidx.lifecycle.Lifecycle.Event.ON_STOP -> lock.noteLeft() - androidx.lifecycle.Lifecycle.Event.ON_START -> - if (lock.shouldLock()) locked = true - else -> Unit + val observer = + androidx.lifecycle.LifecycleEventObserver { _, event -> + when (event) { + androidx.lifecycle.Lifecycle.Event.ON_STOP -> { + lock.noteLeft() + } + + androidx.lifecycle.Lifecycle.Event.ON_START -> { + if (lock.shouldLock()) locked = true + } + + else -> { + Unit + } + } } - } lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) } } @@ -173,10 +152,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { } } - // πŸ”΄ **The handover: exactly one poller at a time.** The ViewModel polls - // while the app is on screen; the service polls while it is not. Two - // pollers would race for the same envelopes and double-notify, so this - // pair has to stay symmetrical β€” if one side moves, move the other. + // The handover: exactly one poller at a time. override fun onStart() { super.onStart() MessengerService.stop(this) @@ -184,9 +160,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { override fun onStop() { super.onStop() - // ⚠️ Only when there is a session to receive for. Starting it signed - // out puts a permanent notice in the shade for an app doing nothing, - // and the service stops itself β€” but not before the user has seen it. + // Only when there is a session to receive for. if (KeystoreVault.hasSession(applicationContext)) MessengerService.start(this) } } @@ -200,7 +174,8 @@ private fun Root(model: MessengerViewModel = viewModel()) { // Scrollable, because a keyboard over a form needs somewhere to go. is MessengerViewModel.Phase.Starting, is MessengerViewModel.Phase.SignedOut, - is MessengerViewModel.Phase.VaultUnreadable -> + is MessengerViewModel.Phase.VaultUnreadable, + -> { Column( modifier = frame.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -212,122 +187,90 @@ private fun Root(model: MessengerViewModel = viewModel()) { else -> Unit } } + } - // NOT scrollable. A `LazyColumn` inside a vertically scrolling - // Column is measured with infinite height and crashes outright β€” - // the lists below manage their own scrolling. - is MessengerViewModel.Phase.SignedIn -> + // NOT scrollable. + is MessengerViewModel.Phase.SignedIn -> { Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) { - // πŸ”΄ **Above the screen switch, not inside one branch.** An - // incoming call has to be visible whatever screen somebody - // is on, and this used to live inside SignedIn β€” which the - // chat screen REPLACES. So a call arriving while you were - // reading a thread showed nothing at all. - // - // Worse, the same banner renders the first-call privacy - // prompt, and the only Call button is IN a chat. Tapping it - // set the prompt state, nothing was composed to show it, - // and the prompt gates placing the call β€” so calling could - // not be started at all. The button looked simply dead. - // πŸ”΄ **A live call takes the screen.** It used to be a - // banner above whatever you were on β€” a line of text and - // two text buttons for the single most attention-demanding - // thing this app does. The privacy prompt stays a banner - // deliberately: it is a question asked BEFORE a call - // exists, and a full screen for it would look like a call - // already happening. + // Above the screen switch, not inside one branch. CallBanner(model, current) StoryComposerDialog(model, current) - // πŸ”΄ **Here, above the screen switch, for the same reason - // the call banner is.** Nothing installs this app but the - // person holding the phone, so the one moment they can be - // told a fix exists is while they have it open β€” whatever - // screen that happens to be. + // Here, above the screen switch, for the same reason the + // call banner is. UpdateAvailableDialog(model.site?.baseUrl) if (LiveCallScreen(model, current)) return@Column val chat = current.openChat if (chat != null) { - ChatScreen(model, chat, current.error, current.buddies, current.avatars, - myUserId = current.userId, groups = current.groups) + ChatScreen( + model, + chat, + current.error, + current.buddies, + current.avatars, + myUserId = current.userId, + groups = current.groups, + ) } else { - // πŸ”΄ **The content area is weighted, and the tab bar is - // not.** These used to be plain siblings, so the tab bar - // sat immediately under whatever the screen had drawn: - // near the TOP of an empty Alerts tab, and pushed clean - // OFF the bottom by the Account tab, which is taller - // than the screen. Account also could not scroll, so the - // sign-out explanation was unreadable and there was no - // way back to another tab. - // - // πŸ”΄ **Every screen scrolls, Contacts included.** - // - // This used to exempt Contacts, because "a lazy list - // inside a vertically scrolling Column is measured with - // infinite height and crashes outright". True β€” but - // only for an UNBOUNDED lazy list, and the buddy list - // has carried `heightIn(max = 320.dp)` since it was put - // in its own card. The exemption outlived its reason. - // - // ⚠️ What it cost: Groups sits below the buddy list on - // this screen, so opening **New group** pushed the - // member checkboxes and the **Create group** button past - // the bottom of a screen that could not scroll. The - // name field was reachable and nothing else was β€” you - // could type a group name and then only Cancel. - // Measured on a 1080Γ—2400 emulator: the field landed at - // yβ‰ˆ1879, the button past the tab bar at yβ‰ˆ2280, and a - // `uiautomator dump` found no scrollable container and - // no "Create group" node at all. - // - // ⚠️ Before exempting a screen here again, check what is - // BELOW the lazy list, not just the list itself. And - // keep the buddy list bounded β€” remove that height and - // this does crash. - // ⚠️ Tighter than it was. Every section already carries - // its own padding, so a 12dp gap between them on top of - // that left the list airy in a way the kit is not β€” its - // rows sit on a 72dp pitch with nothing extra between - // the groups of them. + // The content area is weighted, and the tab bar is not. Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), + modifier = + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(Space.sm), ) { when (current.screen) { - MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current) - MessengerViewModel.Phase.Screen.MESSAGES -> + MessengerViewModel.Phase.Screen.CONTACTS -> { + SignedIn(model, current) + } + + MessengerViewModel.Phase.Screen.MESSAGES -> { MessagesScreen(model, current) - MessengerViewModel.Phase.Screen.CALLS -> + } + + MessengerViewModel.Phase.Screen.CALLS -> { CallsScreen(model, current) - MessengerViewModel.Phase.Screen.STORIES -> + } + + MessengerViewModel.Phase.Screen.STORIES -> { StoriesFeed(model, current) - MessengerViewModel.Phase.Screen.WALLET -> + } + + MessengerViewModel.Phase.Screen.WALLET -> { WalletScreen(model, current) - // πŸ”΄ Its own destination, not a section of the + } + + // Its own destination, not a section of the // wallet. iOS put them together once because - // they share an unlock β€” true, and an - // implementation detail. What it produced was - // somebody pressing "wallet" and arriving at a - // file list. - MessengerViewModel.Phase.Screen.FILES -> + // they share an unlock, true, and an + // implementation detail. + MessengerViewModel.Phase.Screen.FILES -> { FilesScreen(model, current) - MessengerViewModel.Phase.Screen.NOTIFICATIONS -> + } + + MessengerViewModel.Phase.Screen.NOTIFICATIONS -> { NotificationsScreen(model, current) - MessengerViewModel.Phase.Screen.ACCOUNT -> + } + + MessengerViewModel.Phase.Screen.ACCOUNT -> { AccountScreen(model, current) + } } } BottomNav(model, current) } } + } } } } @Composable -private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedOut) { +private fun ColumnScope.SignIn( + model: MessengerViewModel, + phase: MessengerViewModel.Phase.SignedOut, +) { val relay by model.relayInput.collectAsStateWithLifecycle() // Observed, not read once: the account mode is discovered from the relay a // moment after the address is typed, and the label has to catch up. @@ -341,27 +284,18 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo // Only a relay keeping its own accounts needs an address for reset mail. val needsEmail = registering && site?.accountProvider == SiteConfig.AccountProvider.LOCAL - // πŸ”΄ **The kit's Welcome screen, which is a masthead and not a form - // heading.** A logo mark, a 40pt greeting, and one line saying what this - // is β€” centred, with room above it. This was a 24pt product name jammed - // against a Server field, which is the layout of a login dialog rather - // than the first thing anyone sees of the app. See `DESIGN.md`. - // - // ⚠️ The kit's Welcome has no fields on it at all β€” it is a splash before - // a phone number is asked for. Flamenet does not ask for a phone number - // and has nothing to put on a second screen, so the masthead and the form - // share one. The typography and the centring are the kit's; the fields - // are ours. + // The kit's Welcome screen, which is a masthead and not a form heading. Spacer(Modifier.height(Space.xl)) Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(Radius.medium)) - .background(MaterialTheme.colorScheme.primary), + modifier = + Modifier + .size(48.dp) + .clip(RoundedCornerShape(Radius.medium)) + .background(MaterialTheme.colorScheme.primary), contentAlignment = Alignment.Center, ) { Text("F", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White) @@ -370,8 +304,8 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.height(Space.sm)) Text( - "Flamenet Messenger. End-to-end encrypted, no phone number, and it " - + "runs on whichever server you point it at.", + "Flamenet Messenger. End-to-end encrypted, no phone number, and it " + + "runs on whichever server you point it at.", fontSize = 14.sp, color = Palette.SecondaryText, textAlign = TextAlign.Center, @@ -388,9 +322,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo modifier = Modifier.fillMaxWidth().testTag("relay"), ) - // Say which kind of server this is BEFORE the passphrase is typed. Whether - // the passphrase leaves the device differs between the two, and that is not - // something to disclose afterwards. + // Say which kind of server this is BEFORE the passphrase is typed. site?.let { Text( it.identityDescription, @@ -408,18 +340,16 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo modifier = Modifier.fillMaxWidth().testTag("handle"), ) - // Only a relay keeping its own accounts asks for this β€” it is how the - // relay sends a reset code, and it is the only party that ever sees it. - // The shared service does not take one at all, so asking for it there - // would be collecting something nobody needs. + // Only a relay keeping its own accounts asks for this, it is how the relay + // sends a reset code, and it is the only party that ever sees it. if (needsEmail) { OutlinedTextField( value = email, onValueChange = { email = it }, label = { Text("Email") }, singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), modifier = Modifier.fillMaxWidth().testTag("email"), ) Text( @@ -434,31 +364,32 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo label = { Text("Passphrase") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), 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. + // The age gate. 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), + 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.", + "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")) + Text( + it, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("age-error"), + ) } } @@ -466,30 +397,37 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error")) } - // ⚠️ 240 wide and centred, which is the kit's primary button on this - // screen β€” not a full-width slab. A button that spans the phone reads as - // the bottom of a form; one that does not reads as a choice. + // 240 wide and centred, which is the kit's primary button on this screen, + // not a full-width slab. Spacer(Modifier.height(Space.md)) 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. + // 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 + 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." + 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()) && - (!registering || birthYear.length == 4), + enabled = + !phase.busy && handle.isNotBlank() && passphrase.isNotBlank() && + (!needsEmail || email.isNotBlank()) && + (!registering || birthYear.length == 4), shape = RoundedCornerShape(KitButton.radius), modifier = Modifier.width(240.dp).height(KitButton.height).testTag("submit"), ) { @@ -523,14 +461,12 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo /** * Password reset, in place rather than on another screen. - * - * ⚠️ The relay answers a reset request identically whatever the truth is β€” - * unknown handle, no address on file, an account that authenticates elsewhere. - * So the copy here says what was *done*, never what was *found*. "No such - * account" would undo that at the last step. */ @Composable -private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) { +private fun ColumnScope.ResetPassword( + model: MessengerViewModel, + handle: String, +) { var open by remember { mutableStateOf(false) } var code by remember { mutableStateOf("") } var fresh by remember { mutableStateOf("") } @@ -577,52 +513,31 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) /** * What the bar contains, as data rather than as a shape only Compose can see. - * - * πŸ”΄ **Extracted so a test can read it.** The bar was a hand-written run of - * `NavButton` calls, which meant nothing could assert what was in it β€” a - * destination could be dropped in a refactor and no test anywhere would go red. - * It has happened once already in spirit: Files and Wallet lived only inside - * the Account screen for weeks while iOS carried them in its bar, and the two - * apps disagreed with nothing to notice. */ object NavDestinations { - - data class Tab(val screen: MessengerViewModel.Phase.Screen, val label: String, val tag: String) + data class Tab( + val screen: MessengerViewModel.Phase.Screen, + val label: String, + val tag: String, + ) /** * The bar, in order. - * - * ⚠️ **Order matches iOS**, which puts Files and Wallet between Stories and - * Account. The same destination in the same place on both phones is most of - * what "the apps stay in sync" means to somebody holding one of each. - * - * `storiesHidden` is parental supervision: it removes the surface rather - * than showing an empty one. */ - fun tabs(storiesHidden: Boolean): List = listOfNotNull( - Tab(Screen.CONTACTS, "Home", "nav-contacts"), - Tab(Screen.MESSAGES, "Messages", "nav-messages"), - Tab(Screen.CALLS, "Calls", "nav-calls"), - Tab(Screen.STORIES, "Stories", "nav-stories").takeUnless { storiesHidden }, - Tab(Screen.FILES, "Files", "nav-files"), - Tab(Screen.WALLET, "Wallet", "nav-wallet"), - Tab(Screen.ACCOUNT, "Account", "nav-account"), - ) + fun tabs(storiesHidden: Boolean): List = + listOfNotNull( + Tab(Screen.CONTACTS, "Home", "nav-contacts"), + Tab(Screen.MESSAGES, "Messages", "nav-messages"), + Tab(Screen.CALLS, "Calls", "nav-calls"), + Tab(Screen.STORIES, "Stories", "nav-stories").takeUnless { storiesHidden }, + Tab(Screen.FILES, "Files", "nav-files"), + Tab(Screen.WALLET, "Wallet", "nav-wallet"), + Tab(Screen.ACCOUNT, "Account", "nav-account"), + ) } /** * The places to be, always visible. - * - * A tab bar rather than a menu: a capability hidden behind an overflow is one - * most users never find. That was the argument for putting the account here, - * and it is the same argument that brought Files and Wallet here on 2026-09-07 - * β€” they were reachable only by scrolling most of the way down the account - * screen, which is indistinguishable from not shipping them. - * - * ⚠️ **The kit's artboard is four icons and this is now seven.** That is a - * deliberate departure, not an oversight: the earlier note here argued a wallet - * "does not earn the slot", and iOS had already decided otherwise. Two apps - * disagreeing about where a feature lives is worse than either answer. */ @Composable private fun BottomNav( @@ -630,17 +545,14 @@ private fun BottomNav( phase: MessengerViewModel.Phase.SignedIn, ) { val unreadNotes = phase.notifications.count { !it.read } - // πŸ”΄ **A floating pill, not a full-width strip.** The kit's tab bar is a - // 328x50 rounded rectangle inset from both edges, sitting inside an 84-tall - // band β€” it reads as a control resting on the page rather than as the - // bottom of the window. This is the single most recognisable thing about - // the design and the app had a plain row of text buttons. + // A floating pill, not a full-width strip. Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = Kit.pillHeight) - .clip(RoundedCornerShape(Kit.pillRadius)) - .background(MaterialTheme.colorScheme.surfaceVariant), + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = Kit.pillHeight) + .clip(RoundedCornerShape(Kit.pillRadius)) + .background(MaterialTheme.colorScheme.surfaceVariant), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, ) { @@ -648,28 +560,32 @@ private fun BottomNav( val storiesHidden = ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current) for (tab in NavDestinations.tabs(storiesHidden)) { - val badge = when (tab.screen) { - // The inbox badge lives on Messages β€” Home is the roster, this - // is where new mail is read. - Screen.MESSAGES -> phase.unread.size - // Missed calls SINCE the tab was last opened β€” it used to count - // every missed call in the log for ever, a badge nothing could - // clear. Opening the tab is what "seeing" them means. - Screen.CALLS -> model.missedCallsUnseen(phase) - // Alerts left the bar 2026-08-28 and iOS never had one; its - // unread count badges Account on both phones now. - Screen.ACCOUNT -> unreadNotes - else -> 0 - } - val (icon, active) = when (tab.screen) { - Screen.CONTACTS -> KitIcon.Home to KitIcon.Home - Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive - Screen.CALLS -> KitIcon.Call to KitIcon.Call - Screen.STORIES -> KitIcon.Stories to KitIcon.Stories - Screen.FILES -> KitIcon.Files to KitIcon.Files - Screen.WALLET -> KitIcon.Wallet to KitIcon.Wallet - else -> KitIcon.Account to KitIcon.Account - } + val badge = + when (tab.screen) { + // The inbox badge lives on Messages. + Screen.MESSAGES -> phase.unread.size + + // Missed calls SINCE the tab was last opened, it used to count + // every missed call in the log for ever, a badge nothing could + // clear. + Screen.CALLS -> model.missedCallsUnseen(phase) + + // Alerts left the bar 2026-08-28 and iOS never had one; its + // unread count badges Account on both phones now. + Screen.ACCOUNT -> unreadNotes + + else -> 0 + } + val (icon, active) = + when (tab.screen) { + Screen.CONTACTS -> KitIcon.Home to KitIcon.Home + Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive + Screen.CALLS -> KitIcon.Call to KitIcon.Call + Screen.STORIES -> KitIcon.Stories to KitIcon.Stories + Screen.FILES -> KitIcon.Files to KitIcon.Files + Screen.WALLET -> KitIcon.Wallet to KitIcon.Wallet + else -> KitIcon.Account to KitIcon.Account + } NavButton(model, phase, tab.screen, icon, active, tab.label, badge, tab.tag) } } @@ -677,16 +593,6 @@ private fun BottomNav( /** * One tab: a drawn icon, and a count when there is one. - * - * πŸ”΄ **No label.** The kit's bar is four icons and nothing else β€” the artboard - * is literally named "Bottom Bar/Without Label". This was four `TextButton`s - * reading "Contacts Stories Alerts Account", which is a row of links, and - * the counts were parenthesised into those labels: "Contacts (3)". - * - * ⚠️ The label survives as the accessibility name. Dropping the text from the - * screen must not drop it from VoiceOver/TalkBack, or the bar becomes four - * unnamed buttons β€” the version of this that shipped as icons-only with no - * `contentDescription` would be strictly worse than the text it replaced. */ @Composable private fun RowScope.NavButton( @@ -701,45 +607,45 @@ private fun RowScope.NavButton( ) { val selected = phase.screen == screen Box( - modifier = Modifier - .weight(1f) - .height(Kit.pillHeight) - // πŸ”΄ `navigateTo`, not `showScreen`. Wallet and Files each have to - // read the vault before they have anything to draw, so a bare - // `showScreen` lands somebody on an empty screen with nothing - // loading and no error β€” see the note on `navigateTo`. - .clickable { model.navigateTo(screen) } - .semantics { - contentDescription = - if (badge > 0) "$label, $badge unread" else label - } - .testTag(tag), + modifier = + Modifier + .weight(1f) + .height(Kit.pillHeight) + // `navigateTo`, not `showScreen`. + .clickable { model.navigateTo(screen) } + .semantics { + contentDescription = + if (badge > 0) "$label, $badge unread" else label + }.testTag(tag), contentAlignment = Alignment.Center, ) { Icon( if (selected) activeIcon else icon, contentDescription = null, - tint = if (selected) MaterialTheme.colorScheme.primary - else Palette.SecondaryText, + tint = + if (selected) { + MaterialTheme.colorScheme.primary + } else { + Palette.SecondaryText + }, modifier = Modifier.size(Kit.icon), ) if (badge > 0) { Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = Space.md, end = Space.lg) - .size(Space.sm) - .clip(CircleShape) - .background(Palette.Pink), + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = Space.md, end = Space.lg) + .size(Space.sm) + .clip(CircleShape) + .background(Palette.Pink), ) } } } /** - * ⚠️ Internal rather than private ONLY so the JVM screenshot suite can render - * it. `MainActivity` is its one real caller β€” the alternative was a screenshot - * harness that could photograph the app's parts and never a whole screen. + * Internal rather than private ONLY so the JVM screenshot suite can render it. */ @Composable internal fun ColumnScope.SignedIn( @@ -751,28 +657,24 @@ internal fun ColumnScope.SignedIn( var scanning by remember { mutableStateOf(false) } var scannedPrefill by remember { mutableStateOf("") } val scanContext = androidx.compose.ui.platform.LocalContext.current - val askCamera = androidx.activity.compose.rememberLauncherForActivityResult( - androidx.activity.result.contract.ActivityResultContracts.RequestPermission() - ) { _ -> - // Open either way β€” the dialog explains itself when access is refused. - scanning = true - } + val askCamera = + androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts + .RequestPermission(), + ) { _ -> + // Open either way, the dialog explains itself when access is refused. + scanning = true + } - // ⚠️ The contact screen REPLACES the list rather than sitting under it, - // the same way an open chat does. A detail view stacked below a list is - // two screens fighting over one scroll. + // The contact screen REPLACES the list rather than sitting under it, the + // same way an open chat does. val profile = openProfile?.let { id -> phase.buddies.firstOrNull { it.userId == id } } if (profile != null) { ContactDetail(model, phase, profile) { openProfile = null } return } - // πŸ”΄ **Home is two things now, on purpose: your people, and their - // stories.** The conversation list, the groups and the search moved to - // the Messages tab β€” this screen is the kit's social front page: a - // horizontal row of contacts, then everyone's stories. Managing the - // roster stays here (the Add action, and a long-press on a circle for - // the contact's profile). + // Home is two things now, on purpose: your people, and their stories. Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -783,18 +685,22 @@ internal fun ColumnScope.SignedIn( fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f), ) - // Scan their Address screen β€” iOS's home has the same button. A scan - // fills the Add-contact field and stops; nothing is added unlooked-at. + // Scan their Address screen, iOS's home has the same button. BarAction( icon = KitIcon.Scan, label = "Scan an address", tag = "scan-address", onClick = { - val has = androidx.core.content.ContextCompat.checkSelfPermission( - scanContext, android.Manifest.permission.CAMERA - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (has) scanning = true - else askCamera.launch(android.Manifest.permission.CAMERA) + val has = + androidx.core.content.ContextCompat.checkSelfPermission( + scanContext, + android.Manifest.permission.CAMERA, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + if (has) { + scanning = true + } else { + askCamera.launch(android.Manifest.permission.CAMERA) + } }, ) BarAction( @@ -810,12 +716,7 @@ internal fun ColumnScope.SignedIn( onFound = { raw -> scanning = false parseScannedAddress(raw)?.let { card -> - // πŸ”΄ **Bind by the fnid, never the handle (audit LOW).** - // The fnid is derived from the account key and certifies - // itself; a handle is a name the RELAY maps to an account, - // so resolving a scanned code by handle handed the relay - // the choice of who you just scanned. iOS always binds the - // address; this now matches it, same relay or not. + // Bind by the fnid, never the handle (audit LOW). scannedPrefill = "${'$'}{card.fnid}@${'$'}{card.relay}" addingContact = true } @@ -862,11 +763,6 @@ internal fun ColumnScope.SignedIn( /** * One icon-sized action on the trailing edge of the bar. - * - * ⚠️ Glyphs rather than vector assets, because this app ships no icon set and - * pulling one in for four buttons is a dependency for decoration. They are - * sized and spaced like the kit's 24dp icons and carry the real label for - * anyone who cannot see them. */ @Composable private fun BarAction( @@ -876,17 +772,16 @@ private fun BarAction( onClick: () -> Unit, ) { Box( - modifier = Modifier - .size(Kit.icon + Space.md) - .clip(CircleShape) - .clickable(onClick = onClick) - .semantics { contentDescription = label } - .testTag(tag), + modifier = + Modifier + .size(Kit.icon + Space.md) + .clip(CircleShape) + .clickable(onClick = onClick) + .semantics { contentDescription = label } + .testTag(tag), contentAlignment = Alignment.Center, ) { - // πŸ”΄ An `Icon`, not a glyph in a text run. See `KitIcons.kt` β€” the bar - // used to draw "⊞" and "+" as characters, which is most of why this - // screen read as text links rather than as chrome. + // An `Icon`, not a glyph in a text run. Icon( icon, contentDescription = null, @@ -896,7 +791,6 @@ private fun BarAction( } } - @Composable private fun ColumnScope.VaultUnreadable( model: MessengerViewModel, @@ -913,12 +807,11 @@ private fun ColumnScope.VaultUnreadable( "Starting fresh publishes a new device. Everyone you talk to will see " + "\"security keys changed\", so tell them to expect it.", ) - // πŸ”΄ `discardUnreadableVault`, NOT `signOut`. `signOut()` defaults to - // keeping the device, so it left the unreadable vault in place and the next - // restore came straight back here β€” the only button on a dead-end screen - // did nothing but redraw it. - Button(onClick = model::discardUnreadableVault, - modifier = Modifier.testTag("start-fresh")) { + // `discardUnreadableVault`, NOT `signOut`. + Button( + onClick = model::discardUnreadableVault, + modifier = Modifier.testTag("start-fresh"), + ) { Text("Erase and start fresh") } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt index ee72583..238101d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt @@ -54,7 +54,8 @@ fun ColumnScope.MessagesScreen( androidx.activity.result.contract.ActivityResultContracts .RequestPermission(), ) { _ -> - // Open either way, the dialog explains itself when access is refused. + // Open either way, the dialog explains itself when access is + // refused. scanning = true } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt index 79faec6..fa89d9d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt @@ -141,7 +141,8 @@ class MessengerService : Service() { private suspend fun run() { val s = openSession() ?: run { - // Nothing to receive for, signed out, or the vault is unreadable. + // Nothing to receive for, signed out, or the vault is + // unreadable. stopSelf() return } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index ad745ff..525680e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -258,9 +258,10 @@ class MessengerViewModel( _site.value = config.copy(probeFailed = true) return@launch } - // copy(), not a mutation: SiteConfig is a data class and the flow - // conflates on equality, so an in-place write is indistinguishable - // from the value already there and never reaches the UI. + // copy(), not a mutation: SiteConfig is a data class and the + // flow conflates on equality, so an in-place write is + // indistinguishable from the value already there and never + // reaches the UI. _site.value = config.copy(discovered = found) } } @@ -308,8 +309,8 @@ class MessengerViewModel( userId = credential.userId, deviceId = stored.deviceId, relay = SiteConfig(credential.relayBaseUrl).host, - // From the vault, because a restore has no passphrase - // to derive it from. + // From the vault, because a restore has no + // passphrase to derive it from. fnid = credential.fnid, status = "Signed in on this device.", buddies = fetchedBuddies.getOrDefault(emptyList()), @@ -574,14 +575,16 @@ class MessengerViewModel( viewModelScope.launch { val current = session ?: return@launch refreshPresence() - // Snoozes expire by the clock passing and nothing runs at that moment, - // so the set is recomputed whenever the list is drawn rather than - // cached, a stale flag leaves a conversation muted past its deadline. + // Snoozes expire by the clock passing and nothing runs at that + // moment, so the set is recomputed whenever the list is drawn + // rather than cached, a stale flag leaves a conversation muted past + // its deadline. refreshSnoozed() val fetched = withContext(Dispatchers.IO) { runCatching { current.roster() } - // Pin each contact's global address the first time we see them. + // Pin each contact's global address the first time we + // see them. .onSuccess { list -> list.forEach { buddy -> runCatching { @@ -602,9 +605,9 @@ class MessengerViewModel( val added = withContext(Dispatchers.IO) { runCatching { - // Off-server roster (P1): the add is this device's to make, the - // relay only resolved the handle, and that lookup was - // transient. + // Off-server roster (P1): the add is this device's to + // make, the relay only resolved the handle, and that + // lookup was transient. if (current.rosterOffServer()) { val found = signedIn()?.lookedUp @@ -1367,7 +1370,8 @@ class MessengerViewModel( val made = withContext(Dispatchers.IO) { runCatching { - // A fresh X25519 key, not one derived from the passphrase. + // A fresh X25519 key, not one derived from the + // passphrase. val raw = FilesIdentity.rawFromPkcs8(X25519.generate().private.encoded) store.saveFilesIdentityRecord(FilesIdentity.protect(raw, passphrase)) @@ -1797,8 +1801,8 @@ class MessengerViewModel( fun removeBuddy(userId: Long) = act { current -> - // The sealed copy first: in off-server mode it IS the roster, and in - // mirror mode a remove must not resurrect on the next fetch. + // The sealed copy first: in off-server mode it IS the roster, and + // in mirror mode a remove must not resurrect on the next fetch. current.rosterRemoveLocal(userId) if (!current.rosterOffServer()) current.api.removeBuddy(userId) current.roster() @@ -1989,8 +1993,9 @@ class MessengerViewModel( openChat = state.openChat?.copy( disappearSeconds = current.conversations.disappearSeconds(peerId), - // Applying the timer expires anything already past it, so - // the thread has to be re-read rather than left on screen. + // Applying the timer expires anything already past + // it, so the thread has to be re-read rather than + // left on screen. lines = current.history(chat.threadId), ), error = done.exceptionOrNull()?.let(::describe), @@ -2254,12 +2259,12 @@ class MessengerViewModel( CallManager( context = getApplication(), signal = { peerId, signal -> - // Fire and forget on the IO pool: signalling must not block the - // WebRTC callback thread it is raised from. + // Fire and forget on the IO pool: signalling must not block + // the WebRTC callback thread it is raised from. viewModelScope.launch(Dispatchers.IO) { runCatching { session?.sendCallSignal(peerId, signal) } - // This was `runCatching { }` and nothing else, so every - // failure here vanished. + // This was `runCatching { }` and nothing else, so + // every failure here vanished. .onFailure { failure -> if (failure is IdentityChangedException) { calls?.hangUp(notifyPeer = false) @@ -2297,10 +2302,11 @@ class MessengerViewModel( when (state) { CallManager.State.ACTIVE -> callWasConnected = true - // A new call starting, outgoing OR incoming. placeCall - // resets this too, but an incoming call never went - // through placeCall, and inheriting the previous call's - // `true` filed a missed call as answered. + // A new call starting, outgoing OR incoming. + // placeCall resets this too, but an incoming call + // never went through placeCall, and inheriting the + // previous call's `true` filed a missed call as + // answered. CallManager.State.DIALLING, CallManager.State.RINGING, -> callWasConnected = false @@ -2824,8 +2830,8 @@ class MessengerViewModel( withContext(Dispatchers.IO) { runCatching { current.forwardMessage(text, toPeerUserId, toGroup) } } - // The forward lands in ANOTHER thread, so the chat on screen is not - // the one that changed. + // The forward lands in ANOTHER thread, so the chat on screen is not the + // one that changed. update { it.copy( openChat = @@ -2843,9 +2849,8 @@ class MessengerViewModel( ) = viewModelScope.launch { val current = session ?: return@launch val mid = line.id ?: return@launch - // Whose message it is, which is what the other side needs to find - // it: our own id for something we sent, theirs for something they - // did. + // Whose message it is, which is what the other side needs to find it. + // our own id for something we sent, theirs for something they did. val author = if (line.mine) { current.userId @@ -2946,7 +2951,8 @@ class MessengerViewModel( "Report sent. Whoever runs this server will see it; you will not be " + "told what they do." } else { - // A relay may decline reports entirely (reports_not_accepted, 501). + // A relay may decline reports entirely (reports_not_accepted, + // 501). "That report could not be sent. This server may not take reports." } update { it.copy(error = message) } @@ -3317,8 +3323,8 @@ class MessengerViewModel( } result .onSuccess { sent -> - // The badge has to catch up on SUCCESS too, and this is the case - // that actually matters. + // The badge has to catch up on SUCCESS too, and this is the + // case that actually matters. val peer = chat.peerId update { it.copy( @@ -3344,9 +3350,9 @@ class MessengerViewModel( it.copy( error = describe(failure), // A refusal changes what we believe about them, so the - // badge has to catch up in the same breath, otherwise the - // header still says "pinned" while the send just failed - // because it is not. + // badge has to catch up in the same breath, otherwise + // the header still says "pinned" while the send just + // failed because it is not. openChat = chat.peerId?.let { peer -> it.openChat?.copy(trust = current.conversations.trust.state(peer)) @@ -3366,14 +3372,14 @@ class MessengerViewModel( installCallSignalHandler() polling = viewModelScope.launch { - // Make sure the relay holds a digest for this account before the - // first poll. + // Make sure the relay holds a digest for this account before + // the first poll. withContext(Dispatchers.IO) { runCatching { session?.conversations?.ensureDeliveryKey() } } while (true) { - // An offer the background service consumed while the app was - // off screen. + // An offer the background service consumed while the app + // was off screen. PendingIncomingCall.take()?.let { parked -> // Stop the ring. runCatching { @@ -3389,14 +3395,15 @@ class MessengerViewModel( } val current = session if (current != null) { - // A story arriving must appear without the user pulling to - // refresh; it is filed by `receive()` and read back here. + // A story arriving must appear without the user pulling + // to refresh; it is filed by `receive()` and read back + // here. val received = withContext(Dispatchers.IO) { val got = runCatching { current.receive() } - // Cached plaintext for anything nothing refers to any - // more, an expired message's photo, a story past its - // lifetime. + // Cached plaintext for anything nothing refers + // to any more, an expired message's photo, a + // story past its lifetime. runCatching { current.purgeOrphanedAttachments(getApplication().cacheDir) } @@ -3429,15 +3436,16 @@ class MessengerViewModel( ) } } - // Read back every tick rather than only when a thread was - // touched: a story files itself outside the thread map, so - // `threads` is empty for a poll that brought one in. + // Read back every tick rather than only when a thread + // was touched: a story files itself outside the thread + // map, so `threads` is empty for a poll that brought + // one in. val polled = withContext(Dispatchers.IO) { runCatching { current.stories() } } - // Skip THIS tick's update on failure rather than leaving - // the loop, the next poll may well succeed. + // Skip THIS tick's update on failure rather than + // leaving the loop, the next poll may well succeed. polled.getOrNull()?.let { live -> update { state -> if (state.stories.map { it.id } == live.map { it.id }) { @@ -3447,8 +3455,8 @@ class MessengerViewModel( } } } - // A picture arriving is a control message: it touches no - // thread, so it needs its own signal. + // A picture arriving is a control message: it touches + // no thread, so it needs its own signal. if (current.groupNamesUpdated) { current.groupNamesUpdated = false withContext(Dispatchers.IO) { runCatching { current.namedGroups() } } @@ -3484,8 +3492,8 @@ class MessengerViewModel( } } if (threads.isNotEmpty()) { - // Whoever just wrote may not be on the list at all, see - // adoptStrangers. + // Whoever just wrote may not be on the list at all, + // see adoptStrangers. adoptStrangers(threads) update { state -> val open = state.openChat @@ -3498,8 +3506,8 @@ class MessengerViewModel( it } }, - // A thread that is already open is being read, - // so it does not also become unread. + // A thread that is already open is being + // read, so it does not also become unread. unread = state.unread + threads - setOfNotNull(open?.threadId), // Refreshed here, not only at sign-in. remoteAddresses = current.remoteAddresses(), @@ -3507,9 +3515,9 @@ class MessengerViewModel( } } } - // A call being set up lives or dies on signalling latency, the - // 5 s cadence made every answer feel geological, and stacked - // with the same cadence on the other end. + // A call being set up lives or dies on signalling latency, + // the 5 s cadence made every answer feel geological, and + // stacked with the same cadence on the other end. val settingUp = when (calls?.state) { CallManager.State.DIALLING, @@ -3692,9 +3700,9 @@ internal fun describeFailure(failure: Throwable): String = "Too many attempts. Wait a few minutes." } - // Not the underlying message: at this layer it is a Java networking string - // ("Failed to connect to /10.0.2.2:443"), which names an address the person - // never typed. + // Not the underlying message: at this layer it is a Java networking + // string ("Failed to connect to /10.0.2.2:443"), which names an address + // the person never typed. is AccountsClient.Failure.Transport -> { "Couldn't reach that server. Check the address and your connection." } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MetadataScrub.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MetadataScrub.kt index 436c64d..faebe29 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MetadataScrub.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MetadataScrub.kt @@ -6,55 +6,49 @@ import java.io.ByteArrayOutputStream /** * Strips metadata from an image on its way out, whichever door it left by. - * - * πŸ”΄ **A photo sent as a *file* used to keep its GPS; sent as a *photo* it did - * not** (android#17, ios#69). The picker path runs through [ImagePrep], which - * decodes to a bitmap and re-encodes β€” the output is pixels, with no container - * to carry EXIF. `Session.sendFile` handed the stored bytes straight to - * `sendAttachment`. Somebody who had learned "Flamenet strips location from my - * photos" had learned something true of one button and false of the one beside - * it, and the safer one looked like the shortcut. - * - * ⚠️ **Sniffed, not trusted.** The vault stores no MIME type and a file name is - * a guess in both directions: `holiday.txt` can be a JPEG and `photo.jpg` can be - * text. Deciding by extension would strip the wrong things and miss the right - * ones. - * - * ⚠️ **Only images are touched.** "Send this exact file" is what a file send - * means, and silently rewriting somebody's PDF or spreadsheet would be its own - * surprise. Anything not recognised goes byte-for-byte. - * - * Matches `MetadataScrub` on iOS, format list included. */ object MetadataScrub { - enum class ImageKind { JPEG, PNG, HEIC, TIFF, WEBP, GIF } /** What happened, so the caller can say so rather than guess. */ - data class Result(val bytes: ByteArray, val strippedKind: ImageKind?) { + data class Result( + val bytes: ByteArray, + val strippedKind: ImageKind?, + ) { // ByteArray in a data class needs these, or equality compares identity. override fun equals(other: Any?): Boolean = - this === other || (other is Result && - bytes.contentEquals(other.bytes) && strippedKind == other.strippedKind) + this === other || ( + other is Result && + bytes.contentEquals(other.bytes) && strippedKind == other.strippedKind + ) + override fun hashCode(): Int = 31 * bytes.contentHashCode() + (strippedKind?.hashCode() ?: 0) } /** Pure, and the reason this is testable without a device. */ fun kind(of: ByteArray): ImageKind? { fun at(i: Int): Int? = if (i < of.size) of[i].toInt() and 0xFF else null - fun matches(bytes: List, offset: Int = 0): Boolean = - bytes.withIndex().all { (i, b) -> at(offset + i) == b } + + fun matches( + bytes: List, + offset: Int = 0, + ): Boolean = bytes.withIndex().all { (i, b) -> at(offset + i) == b } if (matches(listOf(0xFF, 0xD8, 0xFF))) return ImageKind.JPEG if (matches(listOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))) return ImageKind.PNG if (matches(listOf(0x47, 0x49, 0x46, 0x38))) return ImageKind.GIF - // TIFF, either byte order. ⚠️ Also the container many RAW files use, - // which is exactly why it should be recognised rather than sent raw. + // TIFF, either byte order. if (matches(listOf(0x49, 0x49, 0x2A, 0x00)) || - matches(listOf(0x4D, 0x4D, 0x00, 0x2A))) return ImageKind.TIFF - // RIFF….WEBP β€” the size field sits between the two markers. + matches(listOf(0x4D, 0x4D, 0x00, 0x2A)) + ) { + return ImageKind.TIFF + } + // RIFF….WEBP, the size field sits between the two markers. if (matches(listOf(0x52, 0x49, 0x46, 0x46)) && - matches(listOf(0x57, 0x45, 0x42, 0x50), 8)) return ImageKind.WEBP + matches(listOf(0x57, 0x45, 0x42, 0x50), 8) + ) { + return ImageKind.WEBP + } // ISO-BMFF: "ftyp" at offset 4, then a HEIF brand. if (matches(listOf(0x66, 0x74, 0x79, 0x70), 4)) { val brand = (8..11).mapNotNull { at(it) }.map { it.toChar() }.joinToString("") @@ -68,33 +62,23 @@ object MetadataScrub { /** * Re-encode an image so nothing but pixels survives; pass anything else * through unchanged. - * - * ⚠️ **Re-encoded, not filtered.** Removing the EXIF block by hand leaves - * XMP, IPTC, MakerNote and thumbnail streams, several of which carry - * location independently. Decoding to a bitmap and writing out again is the - * only version of this with no list to keep up to date. - * - * ⚠️ Undecodable bytes are returned as they came. A file that will not - * decode is not necessarily a file with location in it, and losing - * somebody's data to a scrubber is a worse failure than the one being - * fixed. */ fun scrub(data: ByteArray): Result { val kind = kind(data) ?: return Result(data, null) - val raw = runCatching { BitmapFactory.decodeByteArray(data, 0, data.size) }.getOrNull() - ?: return Result(data, null) - // πŸ”΄ This function's whole job is to remove the EXIF block β€” which - // holds the GPS coordinates AND the tag saying which way is up. Turning - // the pixels first is what keeps the second from being collateral - // damage of removing the first. See ImagePrep.orientedFrom. + val raw = + runCatching { BitmapFactory.decodeByteArray(data, 0, data.size) }.getOrNull() + ?: return Result(data, null) + // This function's whole job is to remove the EXIF block, which holds + // the GPS coordinates AND the tag saying which way is up. val bitmap = ImagePrep.orientedFrom(data, raw) val out = ByteArrayOutputStream() // PNG where a lossy pass would be a second surprise; JPEG otherwise, at // a quality high enough not to be visible. - val format = when (kind) { - ImageKind.PNG, ImageKind.GIF, ImageKind.TIFF, ImageKind.WEBP -> Bitmap.CompressFormat.PNG - ImageKind.JPEG, ImageKind.HEIC -> Bitmap.CompressFormat.JPEG - } + val format = + when (kind) { + ImageKind.PNG, ImageKind.GIF, ImageKind.TIFF, ImageKind.WEBP -> Bitmap.CompressFormat.PNG + ImageKind.JPEG, ImageKind.HEIC -> Bitmap.CompressFormat.JPEG + } val ok = runCatching { bitmap.compress(format, 95, out) }.getOrDefault(false) bitmap.recycle() return if (ok && out.size() > 0) Result(out.toByteArray(), kind) else Result(data, null) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt index 571de34..2e252e9 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt @@ -146,9 +146,9 @@ private fun EmojiSection( Modifier .clip(RoundedCornerShape(Radius.small)) .clickable { onPick(e) } - // Scoped by section, as on iOS: the same emoji can sit in - // Recent AND its own group, and a tag on the glyph alone is - // ambiguous to a test. + // Scoped by section, as on iOS: the same emoji can sit + // in Recent AND its own group, and a tag on the glyph + // alone is ambiguous to a test. .testTag("emoji-$title-$e"), ) { Text(e, fontSize = 24.sp, modifier = Modifier.padding(Space.xs)) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 5bdc004..014b2f2 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -411,9 +411,9 @@ class Session( val inUse = try { - // The SAME transformation `attachmentBytes` applies when it names - // the file, comparing raw ids against mangled filenames would find - // no match and delete everything. + // The SAME transformation `attachmentBytes` applies when it + // names the file, comparing raw ids against mangled filenames + // would find no match and delete everything. vault.attachmentIdsInUse().map { it.replace('/', '_') }.toSet() } catch (e: Exception) { return 0 @@ -917,9 +917,9 @@ class Session( .put("to", peerUserId) .put("text", text) .put("at", at) - // Present only for group entries: the bubble to restate lives in - // the GROUP thread, not in a 1:1 thread with this member, which may - // not even exist. + // Present only for group entries: the bubble to restate lives + // in the GROUP thread, not in a 1:1 thread with this member, + // which may not even exist. .also { if (thread != null) it.put("thread", thread) }, ) vault.saveOutbox(pending) @@ -1549,7 +1549,8 @@ class Session( val live = all .filter { it.isActive(now) } - // One place, so every screen agrees about whose stories are hidden. + // One place, so every screen agrees about whose stories are + // hidden. .filter { it.mine || !storiesMuted(it.authorId) } // Written back so expired bytes leave the device rather than lingering // until the next post. @@ -1668,9 +1669,9 @@ class Session( caption = caption.orEmpty(), postedAt = System.currentTimeMillis(), seen = true, - // The poster sees the same gate their contacts will, rather - // than a story that looks different to the person who sent - // it. + // The poster sees the same gate their contacts will, + // rather than a story that looks different to the + // person who sent it. streamUrl = streamUrl, ), now, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt index e019653..3d7eb31 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt @@ -74,16 +74,17 @@ object StoryMedia { retriever .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) ?.toIntOrNull() ?: 0 - // A portrait phone clip is stored landscape plus a rotation. + // A portrait phone clip is stored landscape plus a + // rotation. if (rotation == 90 || rotation == 270) { val t = w w = h h = t } // The rotation is carried out of here now, not just used to - // swap the dimensions: rebuilding the container to strip its - // metadata drops the orientation with everything else, and a - // portrait story would play on its side. + // swap the dimensions: rebuilding the container to strip + // its metadata drops the orientation with everything else, + // and a portrait story would play on its side. Meta(durationMs / 1000.0, w, h, rotation) }.getOrNull().also { runCatching { retriever.release() } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt index f0b0cd4..c2feaaf 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt @@ -180,8 +180,8 @@ fun MessengerTheme(content: @Composable () -> Unit) { ) } else { lightColorScheme( - // The kit's #2298FF on white is about 3.3:1, fine for a bold - // button label, not for small text. + // The kit's #2298FF on white is about 3.3:1, fine for a + // bold button label, not for small text. primary = Palette.Accent, onPrimary = Color.White, secondary = Palette.AccentBright, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt index ff49949..f6c3e64 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt @@ -165,10 +165,10 @@ private fun ColumnScope.PhraseCard( Modifier .fillMaxWidth() .clip(RoundedCornerShape(Radius.small)) - // `Mist`, not `SurfaceAlt`: the card is already SurfaceAlt, and - // two identical greys would leave the words looking like body - // text rather than the one thing on this screen worth copying - // onto paper. + // `Mist`, not `SurfaceAlt`: the card is already SurfaceAlt, + // and two identical greys would leave the words looking + // like body text rather than the one thing on this screen + // worth copying onto paper. .background(Palette.Mist) .padding(Space.sm) .testTag("wallet-phrase"), diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt index e91bc39..be3110a 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt @@ -253,8 +253,8 @@ class OutboxTest { org.json .JSONObject() .put("id", opk.id) - // `Bytes` is internal to :engine, so base64 by hand rather than - // widening its visibility for a test. + // `Bytes` is internal to :engine, so base64 by hand rather + // than widening its visibility for a test. .put( "pub", android.util.Base64.encodeToString(opk.publicKey, android.util.Base64.NO_WRAP), diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/PhotoOrientationTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/PhotoOrientationTest.kt index f9b2ad2..b8eec9b 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/PhotoOrientationTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/PhotoOrientationTest.kt @@ -7,21 +7,8 @@ import java.io.File /** * A photo must arrive the way up it was taken. - * - * πŸ”΄ Reported from a real phone: a photo auto-rotated. `BitmapFactory` does - * NOT apply EXIF orientation, and every image this app sends is re-encoded to - * strip EXIF β€” right, because that block carries the GPS coordinates the shot - * was taken at. But the same block carries the tag saying which way is up, and - * a phone camera rarely writes upright pixels: it writes landscape pixels plus - * "turn this 90Β°". Decode ignoring the tag, then throw the tag away, and every - * portrait photo lands on its side. - * - * ⚠️ These are source-level checks. The rotation itself needs a real Bitmap, - * which is an instrumented test β€” but the wiring is what regressed and is what - * a unit test can hold. See `PARITY.md`. */ class PhotoOrientationTest { - private fun source(path: String): String { val file = File("src/main/kotlin/io/flamenet/messenger/app/$path") assertTrue("missing $path", file.exists()) @@ -32,58 +19,74 @@ class PhotoOrientationTest { @Test fun preparingAPhotoAppliesTheExifTag() { val prep = source("ImagePrep.kt") - assertTrue("prepare() must orient before encoding", - prep.contains("orientedFrom(source, decodeScaled(source, bounds, maxSide)")) - assertTrue("must read the tag rather than guess", - prep.contains("ExifInterface.TAG_ORIENTATION")) + assertTrue( + "prepare() must orient before encoding", + prep.contains("orientedFrom(source, decodeScaled(source, bounds, maxSide)"), + ) + assertTrue( + "must read the tag rather than guess", + prep.contains("ExifInterface.TAG_ORIENTATION"), + ) } - /** πŸ”΄ And so must the scrubber, whose entire job is removing that block. */ + /** And so must the scrubber, whose entire job is removing that block. */ @Test fun scrubbingAppliesTheExifTagBeforeRemovingIt() { - assertTrue("scrub() must orient before re-encoding", - source("MetadataScrub.kt").contains("ImagePrep.orientedFrom(data, raw)")) + assertTrue( + "scrub() must orient before re-encoding", + source("MetadataScrub.kt").contains("ImagePrep.orientedFrom(data, raw)"), + ) } /** Images received from a peer carry the tag too. */ @Test fun renderingAReceivedImageAppliesTheTag() { - assertTrue("decodeBounded must orient what it decodes", - source("ImagePrep.kt").contains("?.let { orientedFrom(source, it) }")) + assertTrue( + "decodeBounded must orient what it decodes", + source("ImagePrep.kt").contains("?.let { orientedFrom(source, it) }"), + ) } /** - * ⚠️ The mirrored tags are rare but real β€” a front camera writes them, and - * a selfie coming back flipped is the same class of bug as one coming back + * The mirrored tags are rare but real, a front camera writes them, and a + * selfie coming back flipped is the same class of bug as one coming back * sideways. */ @Test fun everyOrientationTagIsHandled() { val prep = source("ImagePrep.kt") for (tag in listOf( - "ORIENTATION_ROTATE_90", "ORIENTATION_ROTATE_180", "ORIENTATION_ROTATE_270", - "ORIENTATION_FLIP_HORIZONTAL", "ORIENTATION_FLIP_VERTICAL", - "ORIENTATION_TRANSPOSE", "ORIENTATION_TRANSVERSE", + "ORIENTATION_ROTATE_90", + "ORIENTATION_ROTATE_180", + "ORIENTATION_ROTATE_270", + "ORIENTATION_FLIP_HORIZONTAL", + "ORIENTATION_FLIP_VERTICAL", + "ORIENTATION_TRANSPOSE", + "ORIENTATION_TRANSVERSE", )) { assertTrue("$tag is not handled", prep.contains(tag)) } } /** - * πŸ”΄ `windowSoftInputMode="adjustResize"` is a no-op at targetSdk 35: - * Android 15 forces edge-to-edge, and an edge-to-edge window does not - * resize for the keyboard. The insets have to be handled in Compose. + * `windowSoftInputMode="adjustResize"` is a no-op at targetSdk 35: Android + * 15 forces edge-to-edge, and an edge-to-edge window does not resize for + * the keyboard. */ @Test fun theKeyboardDoesNotCoverWhatIsBeingTypedInto() { val activity = source("MainActivity.kt") - assertTrue("the root must consume the IME inset", - activity.contains("Modifier.fillMaxSize().imePadding()")) - assertTrue("and import it", activity.contains( - "import androidx.compose.foundation.layout.imePadding")) + assertTrue( + "the root must consume the IME inset", + activity.contains("Modifier.fillMaxSize().imePadding()"), + ) + assertTrue( + "and import it", + activity.contains("import androidx.compose.foundation.layout.imePadding"), + ) - // The manifest line is kept β€” it still applies on older targets β€” but - // it must never again be the only thing relied on. + // The manifest line is kept, it still applies on older targets, but it + // must never again be the only thing relied on. val target = File("build.gradle.kts").readText() assertTrue("this test's premise is targetSdk 35", target.contains("targetSdk = 35")) } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt index 62b2f7a..f1881f3 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt @@ -137,16 +137,17 @@ class ScreenshotTest { at = 1_756_300_380_000, delivery = Session.Delivery.DELIVERED, ), - // The state that used to be invisible: sent while they were signed - // out, so no device took it. + // The state that used to be invisible: sent while they were + // signed out, so no device took it. Session.Line( "Are you around later?", mine = true, at = 1_756_300_440_000, delivery = Session.Delivery.PENDING, ), - // A sticker draws bare, no bubble, so this catches the case where - // it falls through to the text path and renders as an empty one. + // A sticker draws bare, no bubble, so this catches the case + // where it falls through to the text path and renders as an + // empty one. Session.Line( "", mine = false, @@ -255,9 +256,10 @@ class ScreenshotTest { ), buddiesLoaded = true, groupsLoaded = true, - // The row's right-hand column and second line only exist when there is - // a thread behind them, so the render has to have one or it photographs - // the empty state and looks like the change never landed. + // The row's right-hand column and second line only exist when there + // is a thread behind them, so the render has to have one or it + // photographs the empty state and looks like the change never + // landed. previews = mapOf( 32L to diff --git a/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt b/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt index 3a73907..3d10527 100644 --- a/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt @@ -170,8 +170,8 @@ class AccountsClient( .post(body.toString().toRequestBody(JSON)) .header("Accept", "application/json") .build(), - // Only these two actually present a passphrase, so only these two can - // meaningfully report one as wrong. + // Only these two actually present a passphrase, so only these two + // can meaningfully report one as wrong. carriesCredentials = path == "sessions" || path == "accounts", ) diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index a8a2061..7d238ae 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -143,8 +143,8 @@ class Conversations( myUserId: Long, mentions: List = emptyList(), /** - * Minted by the caller, not here, because the caller also - * writes the sender's own copy and queues the retries. + * Minted by the caller, not here, because the caller also writes the + * sender's own copy and queues the retries. */ messageId: String? = null, ): Sent { @@ -593,8 +593,8 @@ class Conversations( servedFnid = keys.fnid ?: servedFnid keys.fnid?.let { trust.rememberFnid(toUserId, it) } val full = keys.bundles.associateBy { it.deviceId } - // Union: every device the directory knows, carrying a full bundle - // where the key route returned one. + // Union: every device the directory knows, carrying a full + // bundle where the key route returned one. stubs.map { full[it.deviceId] ?: it } } // The send path had no logging at all, and that is why a message that diff --git a/client/src/main/kotlin/io/flamenet/client/LinkPreviewFetcher.kt b/client/src/main/kotlin/io/flamenet/client/LinkPreviewFetcher.kt index 803b3d6..9af2357 100644 --- a/client/src/main/kotlin/io/flamenet/client/LinkPreviewFetcher.kt +++ b/client/src/main/kotlin/io/flamenet/client/LinkPreviewFetcher.kt @@ -36,8 +36,9 @@ object LinkPreviewFetcher { .Builder() .url(url) .header("Accept", "text/html") - // Identifies as a link preview rather than impersonating a browser. - // a site that wants to refuse these should be able to. + // Identifies as a link preview rather than impersonating a + // browser. a site that wants to refuse these should be able + // to. .header("User-Agent", "FlamenetLinkPreview/1.0") .build() http.newCall(request).execute().use { response -> diff --git a/client/src/main/kotlin/io/flamenet/client/LocalVault.kt b/client/src/main/kotlin/io/flamenet/client/LocalVault.kt index f2e700e..d91b77b 100644 --- a/client/src/main/kotlin/io/flamenet/client/LocalVault.kt +++ b/client/src/main/kotlin/io/flamenet/client/LocalVault.kt @@ -288,8 +288,8 @@ class LocalVault( .filter { it.parentId == parentId } .map { Entry(it, nameOf(it), isMissing = isMissing(it)) } .sortedWith( - // Folders first, then by name; unreadable rows last, where they - // are least in the way of the ones you can act on. + // Folders first, then by name; unreadable rows last, where + // they are least in the way of the ones you can act on. compareByDescending { it.isFolder } .thenBy { it.name == null } .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" }, diff --git a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt index 2813761..18b8569 100644 --- a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt +++ b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt @@ -171,8 +171,8 @@ class MessengerApi( } /** - * Changing the password invalidates other credentials, which is the point - * of changing it, so this device has to sign in again afterwards. + * Changing the password invalidates other credentials of changing it, so + * this device has to sign in again afterwards. */ fun updatePassword(password: String) { raw("api/flamenet/v1/messenger/me", "PUT", JSONObject().put("password", password)) diff --git a/client/src/main/kotlin/io/flamenet/client/RelayAuth.kt b/client/src/main/kotlin/io/flamenet/client/RelayAuth.kt index 9690831..f56fa43 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayAuth.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayAuth.kt @@ -61,9 +61,9 @@ class RelayAuth( ) return null } ?: run { - // A non-2xx that is not 404 arrives here as null, not as a throw, - // so this branch is NOT unreachable, it was the quietest path of - // the three. + // A non-2xx that is not 404 arrives here as null, not as a + // throw, so this branch is NOT unreachable, it was the quietest + // path of the three. warn( "no token: /account/token answered a non-success " + "status; falling back to Basic, which /e2e/* refuses.", diff --git a/client/src/main/kotlin/io/flamenet/client/RelayException.kt b/client/src/main/kotlin/io/flamenet/client/RelayException.kt index dc294bc..36115b5 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayException.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayException.kt @@ -106,9 +106,9 @@ internal object Wire { .getDecoder() .decode(text) - // / The engine keeps its own `Bytes.sha256` `internal`, for the reason - // / above: its public surface is the protocol, not its utilities. Β§21.4 - // / needs one here to hash a capability before uploading it, and SHA-256 has + // / The engine keeps its own `Bytes.sha256` `internal`, for the reason / + // above: its public surface is the protocol, not its utilities. Β§21.4 / + // needs one here to hash a capability before uploading it, and SHA-256 has // / one right answer, so a second call site cannot drift. fun sha256(data: ByteArray): ByteArray = java.security.MessageDigest diff --git a/client/src/main/kotlin/io/flamenet/client/Trust.kt b/client/src/main/kotlin/io/flamenet/client/Trust.kt index f5e77df..3a5b1aa 100644 --- a/client/src/main/kotlin/io/flamenet/client/Trust.kt +++ b/client/src/main/kotlin/io/flamenet/client/Trust.kt @@ -442,10 +442,10 @@ class Trust( when { pin.size > head.size -> false - // shrank + // shrank. pin.size == head.size -> pin.root == head.root - // same size, same root + // same size, same root. else -> consistent(pin.size, head.size, pin.root, head.root) } } catch (_: Exception) { @@ -497,8 +497,8 @@ class Trust( val issuer = when (val verdict = RelayIdentityPin.check(offered, vault.keyLogIssuerPin())) { is RelayIdentityPin.Verdict.NotPublished -> { - // Nothing published and nothing pinned: a weaker deployment, - // not an attack. + // Nothing published and nothing pinned: a weaker + // deployment, not an attack. vault.keyLogIssuerPin() ?: return } diff --git a/client/src/main/kotlin/io/flamenet/client/UpdateCheck.kt b/client/src/main/kotlin/io/flamenet/client/UpdateCheck.kt index 4f9beda..37c0516 100644 --- a/client/src/main/kotlin/io/flamenet/client/UpdateCheck.kt +++ b/client/src/main/kotlin/io/flamenet/client/UpdateCheck.kt @@ -70,7 +70,8 @@ object UpdateCheck { val json = JSONObject(body) val code = json.optInt("versionCode", 0) - // A manifest that cannot say which build it describes is not one. + // A manifest that cannot say which build it describes is + // not one. if (code <= 0) return null Release( versionCode = code, diff --git a/client/src/test/kotlin/io/flamenet/client/DeliveryTokenTest.kt b/client/src/test/kotlin/io/flamenet/client/DeliveryTokenTest.kt index 4c3114e..a413625 100644 --- a/client/src/test/kotlin/io/flamenet/client/DeliveryTokenTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/DeliveryTokenTest.kt @@ -89,8 +89,9 @@ class DeliveryTokenTest { "/e2e/delivery-tokens" in path -> { val b = JSONObject(request.body.readUtf8()) - // Revoke first, so a client cutting one contact off while - // topping another up in the same call keeps what it added. + // Revoke first, so a client cutting one contact off + // while topping another up in the same call keeps what + // it added. b.optJSONArray("revoke")?.let { a -> (0 until a.length()).forEach { held.remove(a.getString(it)) } } diff --git a/client/src/test/kotlin/io/flamenet/client/GossipHeadSentTest.kt b/client/src/test/kotlin/io/flamenet/client/GossipHeadSentTest.kt index eb910c1..edf99d8 100644 --- a/client/src/test/kotlin/io/flamenet/client/GossipHeadSentTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/GossipHeadSentTest.kt @@ -77,9 +77,9 @@ class GossipHeadSentTest { override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse { val path = request.path.orEmpty() return when { - // `contains`, not `startsWith`: every route is under the - // REST mount, so the recorded path begins with that prefix - // rather than with `/e2e`. + // `contains`, not `startsWith`: every route is under + // the REST mount, so the recorded path begins with that + // prefix rather than with `/e2e`. "/e2e/log/" in path -> MockResponse().setResponseCode(404) path.contains("/e2e/messages") -> json("""{"ok":true}""") diff --git a/client/src/test/kotlin/io/flamenet/client/MultiMailboxPollTest.kt b/client/src/test/kotlin/io/flamenet/client/MultiMailboxPollTest.kt index 97c6bb3..210a74a 100644 --- a/client/src/test/kotlin/io/flamenet/client/MultiMailboxPollTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/MultiMailboxPollTest.kt @@ -125,10 +125,10 @@ class MultiMailboxPollTest { relay = backupHost, username = "me", appPassword = "pw", - // The record names a hostname and cannot carry a port; how this - // device DIALS that host is a separate field, which is what lets a - // test, or a self-hoster on a nonstandard port, work without the - // record lying about its own name. + // The record names a hostname and cannot carry a port; how + // this device DIALS that host is a separate field, which is + // what lets a test, or a self-hoster on a nonstandard port, + // work without the record lying about its own name. baseUrl = backup.url("/").toString(), ), ), diff --git a/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt b/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt index 099166c..9041764 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt @@ -103,9 +103,9 @@ object AccountKdf { params: Params = Params.PBKDF2_V1, ): ByteArray = SecureBytes.withSecret(master(passphrase, salt, params)) { ikm -> - // ios#39, the stretched master is an intermediate nobody needs after - // the expansion, and it is the most valuable thing in this file: every - // other secret is derived from it. + // ios#39, the stretched master is an intermediate nobody needs + // after the expansion, and it is the most valuable thing in this + // file: every other secret is derived from it. Hkdf.derive( ikm = ikm, salt = ByteArray(0), diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 836283e..9916c1b 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -372,7 +372,8 @@ data class Content( ): String = assemble( kind = ref.kind, - // A file envelope ALWAYS carries a caption, even with none written. + // A file envelope ALWAYS carries a caption, even with none + // written. caption = caption?.takeIf { it.isNotEmpty() } ?: "πŸ“Ž " @@ -781,8 +782,8 @@ data class Content( } ?: return Content( // The sender meant to say something; a message that - // silently vanishes is the worst way to meet a version this - // client does not know. + // silently vanishes is the worst way to meet a version + // this client does not know. caption.ifEmpty { "[unsupported attachment]" }, kind = kind, raw = json, @@ -856,11 +857,11 @@ object Attachment { // 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 - // 0, which renders as "no expiry" AND, because isActive treats 0 as - // immortal, means an expired story's bytes were never dropped on - // write. + // saveStories persists refs through here, loadStories reads + // "exp" back out of this very JSON, and a field never written + // reloads as 0, which renders as "no expiry" AND, because + // isActive treats 0 as immortal, means an expired story's bytes + // were never dropped on write. .also { if (expires > 0) it.put("exp", expires) } companion object { diff --git a/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt b/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt index c9d511e..4fe4355 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt @@ -80,10 +80,11 @@ class DeviceIdentity private constructor( Ed25519.fromSeed(Bytes.unb64(archive.getJSONObject("ikSig").getString("seed"))), signedPreKey = x(archive.getJSONObject("spk")), signedPreKeyId = archive.getInt("spkId"), - // 0 means "written before this field existed", and the honest - // reading of an unknown age is "old", a device that rotates - // once on first launch after an upgrade is harmless, one that - // never rotates because it defaulted to `now` is not. + // 0 means "written before this field existed", and the + // honest reading of an unknown age is "old", a device that + // rotates once on first launch after an upgrade is + // harmless, one that never rotates because it defaulted to + // `now` is not. signedPreKeyCreatedAt = archive.optLong("spkAt", 0), ) identity.nextPreKeyId = archive.optInt("nextId", 1) diff --git a/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt b/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt index 4247e21..2ef690a 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt @@ -135,14 +135,14 @@ class E2ESession private constructor( val session = E2ESession( - // The seed of the PQ prekey the initiator used, so the first - // inbound ratchet step can decapsulate to it. + // The seed of the PQ prekey the initiator used, so the + // first inbound ratchet step can decapsulate to it. ratchet = DoubleRatchet.responder(sharedSecret, signedPreKey, pq?.first), associatedData = X3DH.associatedData(theirIdentitySigning, identity.identitySigningPublic, pq?.second), pendingHandshake = null, - // Honour what the initiator declared; this could never return - // 3. + // Honour what the initiator declared; this could never + // return 3. version = when { pq == null -> 1 @@ -161,9 +161,10 @@ class E2ESession private constructor( pendingHandshake = archive.optJSONObject("pending"), version = archive.optInt("version", 1), // Archived for the same reason `pending` is: it is part of the - // handshake block, and a session restored without it would go on - // sending prekey envelopes with the post-quantum half missing, - // which the peer would answer by deriving a different key. + // handshake block, and a session restored without it would go + // on sending prekey envelopes with the post-quantum half + // missing, which the peer would answer by deriving a different + // key. pq = archive.optJSONObject("pq")?.let { PQEnvelope( diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt b/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt index 9a5ff3d..5fc66fa 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt @@ -145,6 +145,7 @@ object SealedSender { * @param groupId with [groupName], tells this contact what a group is * called. */ + /** * Β§8.9b, hand a member their tag and the group key. */ diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 5ef69a5..f4ca4e2 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -281,8 +281,8 @@ class Vault( username = it.getString("username"), appPassword = it.getString("appPassword"), userId = it.getLong("userId"), - // Optional read: a credential written before addresses existed is - // still a valid credential, and must not fail to load. + // Optional read: a credential written before addresses existed + // is still a valid credential, and must not fail to load. fnid = it.optString("fnid").ifEmpty { null }, ) } @@ -349,8 +349,8 @@ class Vault( Trust( identities = (0 until a.length()).map { a.getString(it) }, verified = o.optBoolean("verified", false), - // `"null"` is rejected, and that is not paranoia, records written - // that way are already on phones. + // `"null"` is rejected, and that is not paranoia, records + // written that way are already on phones. fnid = o.optString("fnid").takeIf { it.isNotEmpty() && it != "null" }, changed = o.optBoolean("changed", false), ) diff --git a/engine/src/test/kotlin/io/flamenet/e2e/MailboxStateTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/MailboxStateTest.kt index 4e9115e..d67d5b2 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/MailboxStateTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/MailboxStateTest.kt @@ -31,8 +31,8 @@ class MailboxStateTest { private val storage = MapStorage() - // / Held so a SECOND vault can be opened over the same storage, which is - // / what a restart is, and the only way to test that anything persisted. + // / Held so a SECOND vault can be opened over the same storage, which is / + // what a restart is, and the only way to test that anything persisted. private val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private val vault = Vault(storage, key) diff --git a/engine/src/test/kotlin/io/flamenet/e2e/QrCodeRenderForDecodingTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/QrCodeRenderForDecodingTest.kt index 914d36d..477dacf 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/QrCodeRenderForDecodingTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/QrCodeRenderForDecodingTest.kt @@ -52,8 +52,8 @@ class QrCodeRenderForDecodingTest { "flamenet:fnid:kx7q2m", // v1 "flamenet:fnid:" + "a".repeat(52) + "@relay1.flamenet.io", // a real address "flamenet:fnid:" + "b".repeat(52) + "@" + "long".repeat(8) + ".example.com", - // Exactly full, version by version: data codewords minus the mode - // nibble and character count. + // Exactly full, version by version: data codewords minus the + // mode nibble and character count. "x".repeat(14), // v1 "x".repeat(26), // v2 "x".repeat(42), // v3 diff --git a/interop/src/main/kotlin/io/flamenet/interop/Main.kt b/interop/src/main/kotlin/io/flamenet/interop/Main.kt index b612217..78d58cb 100644 --- a/interop/src/main/kotlin/io/flamenet/interop/Main.kt +++ b/interop/src/main/kotlin/io/flamenet/interop/Main.kt @@ -115,7 +115,8 @@ private fun handle(req: JSONObject): JSONObject = decrypt(req) } - // --- the session layer, which is what an app actually uses ------------. + // --- the session layer, which is what an app actually uses + // ------------. "session_device" -> { sessionDevice(req) } diff --git a/obfs4/src/test/kotlin/io/flamenet/obfs4/BridgeLineTest.kt b/obfs4/src/test/kotlin/io/flamenet/obfs4/BridgeLineTest.kt index d55d6b7..bcb21a8 100644 --- a/obfs4/src/test/kotlin/io/flamenet/obfs4/BridgeLineTest.kt +++ b/obfs4/src/test/kotlin/io/flamenet/obfs4/BridgeLineTest.kt @@ -55,9 +55,9 @@ class BridgeLineTest { "obfs4 cert=abc" to BridgeLineProblem.NoAddress, line(address = "198.51.100.7:notaport") to BridgeLineProblem.BadPort("notaport"), "obfs4 198.51.100.7:4444 0123456789ABCDEF" to BridgeLineProblem.NoCert, - // No spaces: fields split on whitespace, so a cert with a space is - // truncated at the space and is a LENGTH problem, not an encoding - // one. + // No spaces: fields split on whitespace, so a cert with a space + // is truncated at the space and is a LENGTH problem, not an + // encoding one. line(cert = "!!!!not@@@@valid####base64$$$$") to BridgeLineProblem.CertNotReadable, ) for ((input, expected) in cases) { diff --git a/obfs4/src/test/kotlin/io/flamenet/obfs4/HandshakeTest.kt b/obfs4/src/test/kotlin/io/flamenet/obfs4/HandshakeTest.kt index feb171a..24888f4 100644 --- a/obfs4/src/test/kotlin/io/flamenet/obfs4/HandshakeTest.kt +++ b/obfs4/src/test/kotlin/io/flamenet/obfs4/HandshakeTest.kt @@ -36,7 +36,7 @@ class HandshakeTest { try { return Obfs4Handshake.parseClientMessage(wire.copyOfRange(0, n), key(o), o.getLong("epoch_hour")) to n } catch (_: Exception) { - // keep reading + // keep reading. } } return null