Merge main and re-derive the formatting pass
Conflicting files take main's version and ktlint and the comment pipeline are re-run on them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
b938d161ba
commit
788be4259e
@@ -184,7 +184,7 @@ dependencies {
|
|||||||
// module's classpath would make `:client` unable to change its own
|
// module's classpath would make `:client` unable to change its own
|
||||||
// dependencies without breaking a screen that never mentioned it.
|
// dependencies without breaking a screen that never mentioned it.
|
||||||
implementation(project(":obfs4"))
|
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
|
// camera decodes on its side. This reads the tag so the rotation can be
|
||||||
// applied before re-encoding drops it. See ImagePrep.orientedFrom.
|
// applied before re-encoding drops it. See ImagePrep.orientedFrom.
|
||||||
implementation("androidx.exifinterface:exifinterface:1.3.7")
|
implementation("androidx.exifinterface:exifinterface:1.3.7")
|
||||||
|
|||||||
@@ -208,7 +208,8 @@ class AppLock(
|
|||||||
) {
|
) {
|
||||||
return
|
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())
|
onFailure(message.toString())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -189,13 +189,13 @@ class CallManager(
|
|||||||
): PeerConnection? {
|
): PeerConnection? {
|
||||||
val config =
|
val config =
|
||||||
PeerConnection.RTCConfiguration(iceServers(turn)).apply {
|
PeerConnection.RTCConfiguration(iceServers(turn)).apply {
|
||||||
// ALL rather than RELAY: direct paths are tried, and TURN is the
|
// ALL rather than RELAY: direct paths are tried, and TURN is
|
||||||
// fallback.
|
// the fallback.
|
||||||
iceTransportsType = PeerConnection.IceTransportsType.ALL
|
iceTransportsType = PeerConnection.IceTransportsType.ALL
|
||||||
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
|
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
|
||||||
// Trickle is off: candidates are gathered before the offer is sent,
|
// Trickle is off: candidates are gathered before the offer is
|
||||||
// so signalling is ONE encrypted envelope each way rather than a
|
// sent, so signalling is ONE encrypted envelope each way rather
|
||||||
// stream of them.
|
// than a stream of them.
|
||||||
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE
|
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE
|
||||||
}
|
}
|
||||||
val observer =
|
val observer =
|
||||||
@@ -235,9 +235,9 @@ class CallManager(
|
|||||||
|
|
||||||
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) {
|
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) {
|
||||||
android.util.Log.i("fnmsg-call", "ice gathering -> $newState")
|
android.util.Log.i("fnmsg-call", "ice gathering -> $newState")
|
||||||
// Hop to the main thread rather than touching the connection
|
// Hop to the main thread rather than touching the
|
||||||
// from here, same JNI rule as above: no WebRTC calls, and no
|
// connection from here, same JNI rule as above: no WebRTC
|
||||||
// exceptions, from inside a WebRTC callback.
|
// calls, and no exceptions, from inside a WebRTC callback.
|
||||||
if (newState == PeerConnection.IceGatheringState.COMPLETE) {
|
if (newState == PeerConnection.IceGatheringState.COMPLETE) {
|
||||||
main.post { runCatching { deliverGatheredSdp() } }
|
main.post { runCatching { deliverGatheredSdp() } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,8 +392,9 @@ fun rememberVideoCall(model: MessengerViewModel): (Long) -> Unit {
|
|||||||
) { granted ->
|
) { granted ->
|
||||||
val peer = pending.value
|
val peer = pending.value
|
||||||
pending.value = null
|
pending.value = null
|
||||||
// Denied still calls, as voice-grade video (their picture, no ours is
|
// Denied still calls, as voice-grade video (their picture, no ours
|
||||||
// wrong; the manager simply has no camera to add, so it degrades).
|
// is wrong; the manager simply has no camera to add, so it
|
||||||
|
// degrades).
|
||||||
if (peer != null && granted) model.requestCall(peer, video = true)
|
if (peer != null && granted) model.requestCall(peer, video = true)
|
||||||
}
|
}
|
||||||
return { peer ->
|
return { peer ->
|
||||||
|
|||||||
@@ -188,8 +188,8 @@ fun ColumnScope.BuddyList(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { model.openChat(buddy.userId, buddy.displayName) }
|
.clickable { model.openChat(buddy.userId, buddy.displayName) }
|
||||||
// Half the kit's 24dp row gap on each side, which makes the
|
// Half the kit's 24dp row gap on each side, which makes
|
||||||
// 72dp pitch it draws between 48dp rows.
|
// the 72dp pitch it draws between 48dp rows.
|
||||||
.padding(vertical = Kit.rowGap / 2)
|
.padding(vertical = Kit.rowGap / 2)
|
||||||
.testTag("buddy-${buddy.userId}"),
|
.testTag("buddy-${buddy.userId}"),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -2312,9 +2312,9 @@ private fun Bubble(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
// The kit insets a received bubble by 40 from the leading edge, the
|
// The kit insets a received bubble by 40 from the leading edge,
|
||||||
// 32dp avatar plus its gutter, and lets a sent one run to the
|
// the 32dp avatar plus its gutter, and lets a sent one run to
|
||||||
// margin.
|
// the margin.
|
||||||
.padding(
|
.padding(
|
||||||
start = if (line.mine) Kit.receivedInset else 0.dp,
|
start = if (line.mine) Kit.receivedInset else 0.dp,
|
||||||
end = if (line.mine) 0.dp else Kit.receivedInset,
|
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)
|
Text("Not delivered", fontSize = 10.sp, color = Palette.Pink)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELIVERED and null both draw nothing: a message that arrived
|
// DELIVERED and null both draw nothing: a message that
|
||||||
// needs no decoration, and null is every received message.
|
// arrived needs no decoration, and null is every received
|
||||||
|
// message.
|
||||||
else -> {
|
else -> {
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
@@ -2441,8 +2442,8 @@ private fun Bubble(
|
|||||||
VoiceBubble(ref, model)
|
VoiceBubble(ref, model)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A document, not a picture: AttachedImage would download a
|
// A document, not a picture: AttachedImage would
|
||||||
// PDF and draw nothing.
|
// download a PDF and draw nothing.
|
||||||
io.flamenet.e2e.Attachment.FILE -> {
|
io.flamenet.e2e.Attachment.FILE -> {
|
||||||
FileBubble(
|
FileBubble(
|
||||||
ref,
|
ref,
|
||||||
@@ -2464,9 +2465,10 @@ private fun Bubble(
|
|||||||
if (line.text.isNotEmpty()) {
|
if (line.text.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
line.text,
|
line.text,
|
||||||
// 16sp regular, per the kit's Body Text 2, which is what
|
// 16sp regular, per the kit's Body Text 2, which is
|
||||||
// `messageTextSize` returns with larger type off, so this
|
// what `messageTextSize` returns with larger type off,
|
||||||
// renders exactly as it always did until somebody asks.
|
// so this renders exactly as it always did until
|
||||||
|
// somebody asks.
|
||||||
fontSize = bodySize.sp,
|
fontSize = bodySize.sp,
|
||||||
color =
|
color =
|
||||||
if (line.mine) {
|
if (line.mine) {
|
||||||
@@ -2732,8 +2734,8 @@ fun ColumnScope.StoryStrip(
|
|||||||
.size(Kit.avatar)
|
.size(Kit.avatar)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
// Unseen is marked rather than styled away: a strip
|
// Unseen is marked rather than styled away: a strip
|
||||||
// where read and unread look alike is a strip nobody
|
// where read and unread look alike is a strip
|
||||||
// comes back to.
|
// nobody comes back to.
|
||||||
.background(
|
.background(
|
||||||
if (story.seen) {
|
if (story.seen) {
|
||||||
Palette.AvatarFill
|
Palette.AvatarFill
|
||||||
@@ -3199,9 +3201,9 @@ fun ColumnScope.AddContactForm(
|
|||||||
) {
|
) {
|
||||||
Text(found.displayName.ifBlank { found.handle })
|
Text(found.displayName.ifBlank { found.handle })
|
||||||
Text("@${found.handle}", style = MaterialTheme.typography.bodySmall)
|
Text("@${found.handle}", style = MaterialTheme.typography.bodySmall)
|
||||||
// Named plainly: identity is checked by comparing safety
|
// Named plainly: identity is checked by comparing safety numbers
|
||||||
// numbers once the conversation exists, and a lookup result is
|
// once the conversation exists, and a lookup result is not a
|
||||||
// not a substitute for that.
|
// substitute for that.
|
||||||
Text(
|
Text(
|
||||||
"Check their safety number after you start talking, this only says the " +
|
"Check their safety number after you start talking, this only says the " +
|
||||||
"handle exists on this server.",
|
"handle exists on this server.",
|
||||||
|
|||||||
@@ -104,49 +104,59 @@ object ImagePrep {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Turn the pixels the way the EXIF tag says, then forget the tag.
|
* 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 {
|
internal fun orientedFrom(
|
||||||
val tag = runCatching {
|
source: ByteArray,
|
||||||
|
bitmap: Bitmap,
|
||||||
|
): Bitmap {
|
||||||
|
val tag =
|
||||||
|
runCatching {
|
||||||
ExifInterface(ByteArrayInputStream(source))
|
ExifInterface(ByteArrayInputStream(source))
|
||||||
.getAttributeInt(ExifInterface.TAG_ORIENTATION,
|
.getAttributeInt(
|
||||||
ExifInterface.ORIENTATION_NORMAL)
|
ExifInterface.TAG_ORIENTATION,
|
||||||
|
ExifInterface.ORIENTATION_NORMAL,
|
||||||
|
)
|
||||||
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
|
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
|
||||||
|
|
||||||
val matrix = Matrix()
|
val matrix = Matrix()
|
||||||
when (tag) {
|
when (tag) {
|
||||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
ExifInterface.ORIENTATION_ROTATE_90 -> {
|
||||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
matrix.postRotate(90f)
|
||||||
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
|
ExifInterface.ORIENTATION_ROTATE_180 -> {
|
||||||
// flipped is the same class of bug as one coming back sideways.
|
matrix.postRotate(180f)
|
||||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
|
}
|
||||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
|
|
||||||
|
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 -> {
|
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||||
matrix.postRotate(90f); matrix.postScale(-1f, 1f)
|
matrix.postRotate(90f)
|
||||||
|
matrix.postScale(-1f, 1f)
|
||||||
}
|
}
|
||||||
|
|
||||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
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 {
|
}
|
||||||
|
val turned =
|
||||||
|
runCatching {
|
||||||
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||||
}.getOrNull() ?: return bitmap
|
}.getOrNull() ?: return bitmap
|
||||||
if (turned !== bitmap) bitmap.recycle()
|
if (turned !== bitmap) bitmap.recycle()
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ object KeystoreVault {
|
|||||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
.setKeySize(256)
|
.setKeySize(256)
|
||||||
// The vault supplies its own nonce per record, and a
|
// The vault supplies its own nonce per record, and a
|
||||||
// repeated nonce under one key is the catastrophic AES-GCM
|
// repeated nonce under one key is the catastrophic AES-
|
||||||
// failure.
|
// GCM failure.
|
||||||
.setRandomizedEncryptionRequired(false)
|
.setRandomizedEncryptionRequired(false)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -62,47 +61,28 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import io.flamenet.messenger.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
|
* `BiometricPrompt` hosts a fragment, so on a plain `ComponentActivity` it
|
||||||
* silently cannot show — the callback never fires and the lock screen sits
|
* silently cannot show, the callback never fires and the lock screen sits there
|
||||||
* there looking broken. `FragmentActivity` extends `ComponentActivity`, so
|
* looking broken.
|
||||||
* `setContent` and everything else is unaffected.
|
|
||||||
*/
|
*/
|
||||||
class MainActivity : androidx.fragment.app.FragmentActivity() {
|
class MainActivity : androidx.fragment.app.FragmentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
// 🔴 **Screenshots off, and out of the recents thumbnail.**
|
// 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.
|
|
||||||
window.setFlags(
|
window.setFlags(
|
||||||
android.view.WindowManager.LayoutParams.FLAG_SECURE,
|
android.view.WindowManager.LayoutParams.FLAG_SECURE,
|
||||||
android.view.WindowManager.LayoutParams.FLAG_SECURE,
|
android.view.WindowManager.LayoutParams.FLAG_SECURE,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ⚠️ Runtime permission on API 33+, which is this app's minSdk. Without
|
// Runtime permission on API 33+, which is this app's minSdk.
|
||||||
// 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".
|
|
||||||
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
|
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)
|
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,25 +90,10 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
|||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
MessengerTheme {
|
MessengerTheme {
|
||||||
// 🔴 **`android:windowSoftInputMode="adjustResize"` stopped doing
|
// `android:windowSoftInputMode="adjustResize"` stopped doing
|
||||||
// anything at targetSdk 35.** Android 15 forces apps that target
|
// anything at targetSdk 35.
|
||||||
// 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.
|
|
||||||
Surface(modifier = Modifier.fillMaxSize().imePadding()) {
|
Surface(modifier = Modifier.fillMaxSize().imePadding()) {
|
||||||
// 🔴 **The gate is around Root, not inside it.** Drawn over
|
// The gate is around Root, not inside it.
|
||||||
// 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.
|
|
||||||
var locked by remember { mutableStateOf(lock.enabled) }
|
var locked by remember { mutableStateOf(lock.enabled) }
|
||||||
|
|
||||||
if (locked) {
|
if (locked) {
|
||||||
@@ -136,18 +101,24 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
|||||||
onUnlock = {
|
onUnlock = {
|
||||||
lock.authenticate(
|
lock.authenticate(
|
||||||
activity = this@MainActivity,
|
activity = this@MainActivity,
|
||||||
onSuccess = { lock.noteUnlocked(); locked = false },
|
onSuccess = {
|
||||||
|
lock.noteUnlocked()
|
||||||
|
locked = false
|
||||||
|
},
|
||||||
onFailure = { /* the screen stays; it says why */ },
|
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
|
// launch is the prompt rather than a wall somebody has
|
||||||
// to tap to discover.
|
// to tap to discover.
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
lock.authenticate(
|
lock.authenticate(
|
||||||
activity = this@MainActivity,
|
activity = this@MainActivity,
|
||||||
onSuccess = { lock.noteUnlocked(); locked = false },
|
onSuccess = {
|
||||||
|
lock.noteUnlocked()
|
||||||
|
locked = false
|
||||||
|
},
|
||||||
onFailure = {},
|
onFailure = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -157,12 +128,20 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
|||||||
|
|
||||||
// Re-arm on the way out, subject to the grace period.
|
// Re-arm on the way out, subject to the grace period.
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
|
val observer =
|
||||||
|
androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
androidx.lifecycle.Lifecycle.Event.ON_STOP -> lock.noteLeft()
|
androidx.lifecycle.Lifecycle.Event.ON_STOP -> {
|
||||||
androidx.lifecycle.Lifecycle.Event.ON_START ->
|
lock.noteLeft()
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.lifecycle.Lifecycle.Event.ON_START -> {
|
||||||
if (lock.shouldLock()) locked = true
|
if (lock.shouldLock()) locked = true
|
||||||
else -> Unit
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lifecycle.addObserver(observer)
|
lifecycle.addObserver(observer)
|
||||||
@@ -173,10 +152,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔴 **The handover: exactly one poller at a time.** The ViewModel polls
|
// The handover: exactly one poller at a time.
|
||||||
// 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.
|
|
||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
MessengerService.stop(this)
|
MessengerService.stop(this)
|
||||||
@@ -184,9 +160,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
|
|||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
super.onStop()
|
super.onStop()
|
||||||
// ⚠️ Only when there is a session to receive for. Starting it signed
|
// Only when there is a session to receive for.
|
||||||
// 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.
|
|
||||||
if (KeystoreVault.hasSession(applicationContext)) MessengerService.start(this)
|
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.
|
// Scrollable, because a keyboard over a form needs somewhere to go.
|
||||||
is MessengerViewModel.Phase.Starting,
|
is MessengerViewModel.Phase.Starting,
|
||||||
is MessengerViewModel.Phase.SignedOut,
|
is MessengerViewModel.Phase.SignedOut,
|
||||||
is MessengerViewModel.Phase.VaultUnreadable ->
|
is MessengerViewModel.Phase.VaultUnreadable,
|
||||||
|
-> {
|
||||||
Column(
|
Column(
|
||||||
modifier = frame.verticalScroll(rememberScrollState()),
|
modifier = frame.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
@@ -212,122 +187,90 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
|||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NOT scrollable. A `LazyColumn` inside a vertically scrolling
|
// NOT scrollable.
|
||||||
// Column is measured with infinite height and crashes outright —
|
is MessengerViewModel.Phase.SignedIn -> {
|
||||||
// the lists below manage their own scrolling.
|
|
||||||
is MessengerViewModel.Phase.SignedIn ->
|
|
||||||
Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
// 🔴 **Above the screen switch, not inside one branch.** An
|
// Above the screen switch, not inside one branch.
|
||||||
// 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.
|
|
||||||
CallBanner(model, current)
|
CallBanner(model, current)
|
||||||
StoryComposerDialog(model, current)
|
StoryComposerDialog(model, current)
|
||||||
// 🔴 **Here, above the screen switch, for the same reason
|
// Here, above the screen switch, for the same reason the
|
||||||
// the call banner is.** Nothing installs this app but the
|
// call banner is.
|
||||||
// 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.
|
|
||||||
UpdateAvailableDialog(model.site?.baseUrl)
|
UpdateAvailableDialog(model.site?.baseUrl)
|
||||||
if (LiveCallScreen(model, current)) return@Column
|
if (LiveCallScreen(model, current)) return@Column
|
||||||
|
|
||||||
val chat = current.openChat
|
val chat = current.openChat
|
||||||
if (chat != null) {
|
if (chat != null) {
|
||||||
ChatScreen(model, chat, current.error, current.buddies, current.avatars,
|
ChatScreen(
|
||||||
myUserId = current.userId, groups = current.groups)
|
model,
|
||||||
|
chat,
|
||||||
|
current.error,
|
||||||
|
current.buddies,
|
||||||
|
current.avatars,
|
||||||
|
myUserId = current.userId,
|
||||||
|
groups = current.groups,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// 🔴 **The content area is weighted, and the tab bar is
|
// The content area is weighted, and the tab bar is not.
|
||||||
// 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.
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
||||||
) {
|
) {
|
||||||
when (current.screen) {
|
when (current.screen) {
|
||||||
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
|
MessengerViewModel.Phase.Screen.CONTACTS -> {
|
||||||
MessengerViewModel.Phase.Screen.MESSAGES ->
|
SignedIn(model, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
MessengerViewModel.Phase.Screen.MESSAGES -> {
|
||||||
MessagesScreen(model, current)
|
MessagesScreen(model, current)
|
||||||
MessengerViewModel.Phase.Screen.CALLS ->
|
}
|
||||||
|
|
||||||
|
MessengerViewModel.Phase.Screen.CALLS -> {
|
||||||
CallsScreen(model, current)
|
CallsScreen(model, current)
|
||||||
MessengerViewModel.Phase.Screen.STORIES ->
|
}
|
||||||
|
|
||||||
|
MessengerViewModel.Phase.Screen.STORIES -> {
|
||||||
StoriesFeed(model, current)
|
StoriesFeed(model, current)
|
||||||
MessengerViewModel.Phase.Screen.WALLET ->
|
}
|
||||||
|
|
||||||
|
MessengerViewModel.Phase.Screen.WALLET -> {
|
||||||
WalletScreen(model, current)
|
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
|
// wallet. iOS put them together once because
|
||||||
// they share an unlock — true, and an
|
// they share an unlock, true, and an
|
||||||
// implementation detail. What it produced was
|
// implementation detail.
|
||||||
// somebody pressing "wallet" and arriving at a
|
MessengerViewModel.Phase.Screen.FILES -> {
|
||||||
// file list.
|
|
||||||
MessengerViewModel.Phase.Screen.FILES ->
|
|
||||||
FilesScreen(model, current)
|
FilesScreen(model, current)
|
||||||
MessengerViewModel.Phase.Screen.NOTIFICATIONS ->
|
}
|
||||||
|
|
||||||
|
MessengerViewModel.Phase.Screen.NOTIFICATIONS -> {
|
||||||
NotificationsScreen(model, current)
|
NotificationsScreen(model, current)
|
||||||
MessengerViewModel.Phase.Screen.ACCOUNT ->
|
}
|
||||||
|
|
||||||
|
MessengerViewModel.Phase.Screen.ACCOUNT -> {
|
||||||
AccountScreen(model, current)
|
AccountScreen(model, current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
BottomNav(model, current)
|
BottomNav(model, current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@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()
|
val relay by model.relayInput.collectAsStateWithLifecycle()
|
||||||
// Observed, not read once: the account mode is discovered from the relay a
|
// 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.
|
// moment after the address is typed, and the label has to catch up.
|
||||||
@@ -341,24 +284,15 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
|||||||
// Only a relay keeping its own accounts needs an address for reset mail.
|
// Only a relay keeping its own accounts needs an address for reset mail.
|
||||||
val needsEmail = registering && site?.accountProvider == SiteConfig.AccountProvider.LOCAL
|
val needsEmail = registering && site?.accountProvider == SiteConfig.AccountProvider.LOCAL
|
||||||
|
|
||||||
// 🔴 **The kit's Welcome screen, which is a masthead and not a form
|
// The kit's Welcome screen, which is a masthead and not a form heading.
|
||||||
// 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.
|
|
||||||
Spacer(Modifier.height(Space.xl))
|
Spacer(Modifier.height(Space.xl))
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.clip(RoundedCornerShape(Radius.medium))
|
.clip(RoundedCornerShape(Radius.medium))
|
||||||
.background(MaterialTheme.colorScheme.primary),
|
.background(MaterialTheme.colorScheme.primary),
|
||||||
@@ -370,8 +304,8 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
|||||||
Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold)
|
Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold)
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
Text(
|
Text(
|
||||||
"Flamenet Messenger. End-to-end encrypted, no phone number, and it "
|
"Flamenet Messenger. End-to-end encrypted, no phone number, and it " +
|
||||||
+ "runs on whichever server you point it at.",
|
"runs on whichever server you point it at.",
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@@ -388,9 +322,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
|||||||
modifier = Modifier.fillMaxWidth().testTag("relay"),
|
modifier = Modifier.fillMaxWidth().testTag("relay"),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Say which kind of server this is BEFORE the passphrase is typed. Whether
|
// Say which kind of server this is BEFORE the passphrase is typed.
|
||||||
// the passphrase leaves the device differs between the two, and that is not
|
|
||||||
// something to disclose afterwards.
|
|
||||||
site?.let {
|
site?.let {
|
||||||
Text(
|
Text(
|
||||||
it.identityDescription,
|
it.identityDescription,
|
||||||
@@ -408,18 +340,16 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
|||||||
modifier = Modifier.fillMaxWidth().testTag("handle"),
|
modifier = Modifier.fillMaxWidth().testTag("handle"),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Only a relay keeping its own accounts asks for this — it is how the
|
// Only a relay keeping its own accounts asks for this, it is how the relay
|
||||||
// relay sends a reset code, and it is the only party that ever sees it.
|
// 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.
|
|
||||||
if (needsEmail) {
|
if (needsEmail) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = email,
|
value = email,
|
||||||
onValueChange = { email = it },
|
onValueChange = { email = it },
|
||||||
label = { Text("Email") },
|
label = { Text("Email") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions =
|
||||||
keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
|
KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
|
||||||
modifier = Modifier.fillMaxWidth().testTag("email"),
|
modifier = Modifier.fillMaxWidth().testTag("email"),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
@@ -434,31 +364,32 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
|||||||
label = { Text("Passphrase") },
|
label = { Text("Passphrase") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions =
|
||||||
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||||
modifier = Modifier.fillMaxWidth().testTag("passphrase"),
|
modifier = Modifier.fillMaxWidth().testTag("passphrase"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (registering) {
|
if (registering) {
|
||||||
// The age gate. Neutral, and stated honestly: the year is checked on
|
// The age gate.
|
||||||
// this device to refuse an under-13 sign-up, then forgotten. Nothing
|
|
||||||
// about age is sent to any server.
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = birthYear,
|
value = birthYear,
|
||||||
onValueChange = { birthYear = it.filter(Char::isDigit).take(4) },
|
onValueChange = { birthYear = it.filter(Char::isDigit).take(4) },
|
||||||
label = { Text("Year of birth") },
|
label = { Text("Year of birth") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions =
|
||||||
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
|
KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
|
||||||
modifier = Modifier.fillMaxWidth().testTag("birth-year"),
|
modifier = Modifier.fillMaxWidth().testTag("birth-year"),
|
||||||
)
|
)
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
ageError?.let {
|
ageError?.let {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error,
|
Text(
|
||||||
modifier = Modifier.testTag("age-error"))
|
it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.testTag("age-error"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,28 +397,35 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
|||||||
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error"))
|
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⚠️ 240 wide and centred, which is the kit's primary button on this
|
// 240 wide and centred, which is the kit's primary button on this screen,
|
||||||
// screen — not a full-width slab. A button that spans the phone reads as
|
// not a full-width slab.
|
||||||
// the bottom of a form; one that does not reads as a choice.
|
|
||||||
Spacer(Modifier.height(Space.md))
|
Spacer(Modifier.height(Space.md))
|
||||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (registering) {
|
if (registering) {
|
||||||
// The most conservative reading — a birthday not yet
|
// The most conservative reading, a birthday not yet passed
|
||||||
// passed this year — because "maybe old enough" isn't.
|
// this year, because "maybe old enough" isn't.
|
||||||
val year = birthYear.toIntOrNull() ?: 0
|
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) {
|
if (year < 1900 || age < 14) {
|
||||||
ageError = if (year < 1900) "Enter your year of birth."
|
ageError =
|
||||||
else "You must be at least 13 to use Flamenet Messenger."
|
if (year < 1900) {
|
||||||
|
"Enter your year of birth."
|
||||||
|
} else {
|
||||||
|
"You must be at least 13 to use Flamenet Messenger."
|
||||||
|
}
|
||||||
return@Button
|
return@Button
|
||||||
}
|
}
|
||||||
ageError = null
|
ageError = null
|
||||||
}
|
}
|
||||||
model.signIn(handle.trim(), passphrase, register = registering, email = email.trim())
|
model.signIn(handle.trim(), passphrase, register = registering, email = email.trim())
|
||||||
},
|
},
|
||||||
enabled = !phase.busy && handle.isNotBlank() && passphrase.isNotBlank() &&
|
enabled =
|
||||||
|
!phase.busy && handle.isNotBlank() && passphrase.isNotBlank() &&
|
||||||
(!needsEmail || email.isNotBlank()) &&
|
(!needsEmail || email.isNotBlank()) &&
|
||||||
(!registering || birthYear.length == 4),
|
(!registering || birthYear.length == 4),
|
||||||
shape = RoundedCornerShape(KitButton.radius),
|
shape = RoundedCornerShape(KitButton.radius),
|
||||||
@@ -523,14 +461,12 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Password reset, in place rather than on another screen.
|
* 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
|
@Composable
|
||||||
private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) {
|
private fun ColumnScope.ResetPassword(
|
||||||
|
model: MessengerViewModel,
|
||||||
|
handle: String,
|
||||||
|
) {
|
||||||
var open by remember { mutableStateOf(false) }
|
var open by remember { mutableStateOf(false) }
|
||||||
var code by remember { mutableStateOf("") }
|
var code by remember { mutableStateOf("") }
|
||||||
var fresh by remember { mutableStateOf("") }
|
var fresh by remember { mutableStateOf("") }
|
||||||
@@ -577,29 +513,19 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* What the bar contains, as data rather than as a shape only Compose can see.
|
* 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 {
|
object NavDestinations {
|
||||||
|
data class Tab(
|
||||||
data class Tab(val screen: MessengerViewModel.Phase.Screen, val label: String, val tag: String)
|
val screen: MessengerViewModel.Phase.Screen,
|
||||||
|
val label: String,
|
||||||
|
val tag: String,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The bar, in order.
|
* 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<Tab> = listOfNotNull(
|
fun tabs(storiesHidden: Boolean): List<Tab> =
|
||||||
|
listOfNotNull(
|
||||||
Tab(Screen.CONTACTS, "Home", "nav-contacts"),
|
Tab(Screen.CONTACTS, "Home", "nav-contacts"),
|
||||||
Tab(Screen.MESSAGES, "Messages", "nav-messages"),
|
Tab(Screen.MESSAGES, "Messages", "nav-messages"),
|
||||||
Tab(Screen.CALLS, "Calls", "nav-calls"),
|
Tab(Screen.CALLS, "Calls", "nav-calls"),
|
||||||
@@ -612,17 +538,6 @@ object NavDestinations {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The places to be, always visible.
|
* 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
|
@Composable
|
||||||
private fun BottomNav(
|
private fun BottomNav(
|
||||||
@@ -630,13 +545,10 @@ private fun BottomNav(
|
|||||||
phase: MessengerViewModel.Phase.SignedIn,
|
phase: MessengerViewModel.Phase.SignedIn,
|
||||||
) {
|
) {
|
||||||
val unreadNotes = phase.notifications.count { !it.read }
|
val unreadNotes = phase.notifications.count { !it.read }
|
||||||
// 🔴 **A floating pill, not a full-width strip.** The kit's tab bar is a
|
// A floating pill, not a full-width strip.
|
||||||
// 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.
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = Kit.pillHeight)
|
.heightIn(min = Kit.pillHeight)
|
||||||
.clip(RoundedCornerShape(Kit.pillRadius))
|
.clip(RoundedCornerShape(Kit.pillRadius))
|
||||||
@@ -648,20 +560,24 @@ private fun BottomNav(
|
|||||||
val storiesHidden =
|
val storiesHidden =
|
||||||
ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)
|
ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)
|
||||||
for (tab in NavDestinations.tabs(storiesHidden)) {
|
for (tab in NavDestinations.tabs(storiesHidden)) {
|
||||||
val badge = when (tab.screen) {
|
val badge =
|
||||||
// The inbox badge lives on Messages — Home is the roster, this
|
when (tab.screen) {
|
||||||
// is where new mail is read.
|
// The inbox badge lives on Messages.
|
||||||
Screen.MESSAGES -> phase.unread.size
|
Screen.MESSAGES -> phase.unread.size
|
||||||
// Missed calls SINCE the tab was last opened — it used to count
|
|
||||||
|
// Missed calls SINCE the tab was last opened, it used to count
|
||||||
// every missed call in the log for ever, a badge nothing could
|
// every missed call in the log for ever, a badge nothing could
|
||||||
// clear. Opening the tab is what "seeing" them means.
|
// clear.
|
||||||
Screen.CALLS -> model.missedCallsUnseen(phase)
|
Screen.CALLS -> model.missedCallsUnseen(phase)
|
||||||
|
|
||||||
// Alerts left the bar 2026-08-28 and iOS never had one; its
|
// Alerts left the bar 2026-08-28 and iOS never had one; its
|
||||||
// unread count badges Account on both phones now.
|
// unread count badges Account on both phones now.
|
||||||
Screen.ACCOUNT -> unreadNotes
|
Screen.ACCOUNT -> unreadNotes
|
||||||
|
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
val (icon, active) = when (tab.screen) {
|
val (icon, active) =
|
||||||
|
when (tab.screen) {
|
||||||
Screen.CONTACTS -> KitIcon.Home to KitIcon.Home
|
Screen.CONTACTS -> KitIcon.Home to KitIcon.Home
|
||||||
Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive
|
Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive
|
||||||
Screen.CALLS -> KitIcon.Call to KitIcon.Call
|
Screen.CALLS -> KitIcon.Call to KitIcon.Call
|
||||||
@@ -677,16 +593,6 @@ private fun BottomNav(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One tab: a drawn icon, and a count when there is one.
|
* 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
|
@Composable
|
||||||
private fun RowScope.NavButton(
|
private fun RowScope.NavButton(
|
||||||
@@ -701,31 +607,33 @@ private fun RowScope.NavButton(
|
|||||||
) {
|
) {
|
||||||
val selected = phase.screen == screen
|
val selected = phase.screen == screen
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(Kit.pillHeight)
|
.height(Kit.pillHeight)
|
||||||
// 🔴 `navigateTo`, not `showScreen`. Wallet and Files each have to
|
// `navigateTo`, not `showScreen`.
|
||||||
// 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) }
|
.clickable { model.navigateTo(screen) }
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription =
|
contentDescription =
|
||||||
if (badge > 0) "$label, $badge unread" else label
|
if (badge > 0) "$label, $badge unread" else label
|
||||||
}
|
}.testTag(tag),
|
||||||
.testTag(tag),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
if (selected) activeIcon else icon,
|
if (selected) activeIcon else icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = if (selected) MaterialTheme.colorScheme.primary
|
tint =
|
||||||
else Palette.SecondaryText,
|
if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
Palette.SecondaryText
|
||||||
|
},
|
||||||
modifier = Modifier.size(Kit.icon),
|
modifier = Modifier.size(Kit.icon),
|
||||||
)
|
)
|
||||||
if (badge > 0) {
|
if (badge > 0) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(top = Space.md, end = Space.lg)
|
.padding(top = Space.md, end = Space.lg)
|
||||||
.size(Space.sm)
|
.size(Space.sm)
|
||||||
@@ -737,9 +645,7 @@ private fun RowScope.NavButton(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⚠️ Internal rather than private ONLY so the JVM screenshot suite can render
|
* Internal rather than private ONLY so the JVM screenshot suite can render it.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ColumnScope.SignedIn(
|
internal fun ColumnScope.SignedIn(
|
||||||
@@ -751,28 +657,24 @@ internal fun ColumnScope.SignedIn(
|
|||||||
var scanning by remember { mutableStateOf(false) }
|
var scanning by remember { mutableStateOf(false) }
|
||||||
var scannedPrefill by remember { mutableStateOf("") }
|
var scannedPrefill by remember { mutableStateOf("") }
|
||||||
val scanContext = androidx.compose.ui.platform.LocalContext.current
|
val scanContext = androidx.compose.ui.platform.LocalContext.current
|
||||||
val askCamera = androidx.activity.compose.rememberLauncherForActivityResult(
|
val askCamera =
|
||||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
androidx.activity.compose.rememberLauncherForActivityResult(
|
||||||
|
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
|
scanning = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⚠️ The contact screen REPLACES the list rather than sitting under it,
|
// The contact screen REPLACES the list rather than sitting under it, the
|
||||||
// the same way an open chat does. A detail view stacked below a list is
|
// same way an open chat does.
|
||||||
// two screens fighting over one scroll.
|
|
||||||
val profile = openProfile?.let { id -> phase.buddies.firstOrNull { it.userId == id } }
|
val profile = openProfile?.let { id -> phase.buddies.firstOrNull { it.userId == id } }
|
||||||
if (profile != null) {
|
if (profile != null) {
|
||||||
ContactDetail(model, phase, profile) { openProfile = null }
|
ContactDetail(model, phase, profile) { openProfile = null }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔴 **Home is two things now, on purpose: your people, and their
|
// Home is two things now, on purpose: your people, and their stories.
|
||||||
// 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).
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -783,18 +685,22 @@ internal fun ColumnScope.SignedIn(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
// Scan their Address screen — iOS's home has the same button. A scan
|
// Scan their Address screen, iOS's home has the same button.
|
||||||
// fills the Add-contact field and stops; nothing is added unlooked-at.
|
|
||||||
BarAction(
|
BarAction(
|
||||||
icon = KitIcon.Scan,
|
icon = KitIcon.Scan,
|
||||||
label = "Scan an address",
|
label = "Scan an address",
|
||||||
tag = "scan-address",
|
tag = "scan-address",
|
||||||
onClick = {
|
onClick = {
|
||||||
val has = androidx.core.content.ContextCompat.checkSelfPermission(
|
val has =
|
||||||
scanContext, android.Manifest.permission.CAMERA
|
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||||
|
scanContext,
|
||||||
|
android.Manifest.permission.CAMERA,
|
||||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
if (has) scanning = true
|
if (has) {
|
||||||
else askCamera.launch(android.Manifest.permission.CAMERA)
|
scanning = true
|
||||||
|
} else {
|
||||||
|
askCamera.launch(android.Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
BarAction(
|
BarAction(
|
||||||
@@ -810,12 +716,7 @@ internal fun ColumnScope.SignedIn(
|
|||||||
onFound = { raw ->
|
onFound = { raw ->
|
||||||
scanning = false
|
scanning = false
|
||||||
parseScannedAddress(raw)?.let { card ->
|
parseScannedAddress(raw)?.let { card ->
|
||||||
// 🔴 **Bind by the fnid, never the handle (audit LOW).**
|
// 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.
|
|
||||||
scannedPrefill = "${'$'}{card.fnid}@${'$'}{card.relay}"
|
scannedPrefill = "${'$'}{card.fnid}@${'$'}{card.relay}"
|
||||||
addingContact = true
|
addingContact = true
|
||||||
}
|
}
|
||||||
@@ -862,11 +763,6 @@ internal fun ColumnScope.SignedIn(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One icon-sized action on the trailing edge of the bar.
|
* 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
|
@Composable
|
||||||
private fun BarAction(
|
private fun BarAction(
|
||||||
@@ -876,7 +772,8 @@ private fun BarAction(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(Kit.icon + Space.md)
|
.size(Kit.icon + Space.md)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
@@ -884,9 +781,7 @@ private fun BarAction(
|
|||||||
.testTag(tag),
|
.testTag(tag),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
// 🔴 An `Icon`, not a glyph in a text run. See `KitIcons.kt` — the bar
|
// An `Icon`, not a glyph in a text run.
|
||||||
// used to draw "⊞" and "+" as characters, which is most of why this
|
|
||||||
// screen read as text links rather than as chrome.
|
|
||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -896,7 +791,6 @@ private fun BarAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.VaultUnreadable(
|
private fun ColumnScope.VaultUnreadable(
|
||||||
model: MessengerViewModel,
|
model: MessengerViewModel,
|
||||||
@@ -913,12 +807,11 @@ private fun ColumnScope.VaultUnreadable(
|
|||||||
"Starting fresh publishes a new device. Everyone you talk to will see " +
|
"Starting fresh publishes a new device. Everyone you talk to will see " +
|
||||||
"\"security keys changed\", so tell them to expect it.",
|
"\"security keys changed\", so tell them to expect it.",
|
||||||
)
|
)
|
||||||
// 🔴 `discardUnreadableVault`, NOT `signOut`. `signOut()` defaults to
|
// `discardUnreadableVault`, NOT `signOut`.
|
||||||
// keeping the device, so it left the unreadable vault in place and the next
|
Button(
|
||||||
// restore came straight back here — the only button on a dead-end screen
|
onClick = model::discardUnreadableVault,
|
||||||
// did nothing but redraw it.
|
modifier = Modifier.testTag("start-fresh"),
|
||||||
Button(onClick = model::discardUnreadableVault,
|
) {
|
||||||
modifier = Modifier.testTag("start-fresh")) {
|
|
||||||
Text("Erase and start fresh")
|
Text("Erase and start fresh")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ fun ColumnScope.MessagesScreen(
|
|||||||
androidx.activity.result.contract.ActivityResultContracts
|
androidx.activity.result.contract.ActivityResultContracts
|
||||||
.RequestPermission(),
|
.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
|
scanning = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,8 @@ class MessengerService : Service() {
|
|||||||
private suspend fun run() {
|
private suspend fun run() {
|
||||||
val s =
|
val s =
|
||||||
openSession() ?: run {
|
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()
|
stopSelf()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,9 +258,10 @@ class MessengerViewModel(
|
|||||||
_site.value = config.copy(probeFailed = true)
|
_site.value = config.copy(probeFailed = true)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
// copy(), not a mutation: SiteConfig is a data class and the flow
|
// copy(), not a mutation: SiteConfig is a data class and the
|
||||||
// conflates on equality, so an in-place write is indistinguishable
|
// flow conflates on equality, so an in-place write is
|
||||||
// from the value already there and never reaches the UI.
|
// indistinguishable from the value already there and never
|
||||||
|
// reaches the UI.
|
||||||
_site.value = config.copy(discovered = found)
|
_site.value = config.copy(discovered = found)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,8 +309,8 @@ class MessengerViewModel(
|
|||||||
userId = credential.userId,
|
userId = credential.userId,
|
||||||
deviceId = stored.deviceId,
|
deviceId = stored.deviceId,
|
||||||
relay = SiteConfig(credential.relayBaseUrl).host,
|
relay = SiteConfig(credential.relayBaseUrl).host,
|
||||||
// From the vault, because a restore has no passphrase
|
// From the vault, because a restore has no
|
||||||
// to derive it from.
|
// passphrase to derive it from.
|
||||||
fnid = credential.fnid,
|
fnid = credential.fnid,
|
||||||
status = "Signed in on this device.",
|
status = "Signed in on this device.",
|
||||||
buddies = fetchedBuddies.getOrDefault(emptyList()),
|
buddies = fetchedBuddies.getOrDefault(emptyList()),
|
||||||
@@ -574,14 +575,16 @@ class MessengerViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val current = session ?: return@launch
|
val current = session ?: return@launch
|
||||||
refreshPresence()
|
refreshPresence()
|
||||||
// Snoozes expire by the clock passing and nothing runs at that moment,
|
// Snoozes expire by the clock passing and nothing runs at that
|
||||||
// so the set is recomputed whenever the list is drawn rather than
|
// moment, so the set is recomputed whenever the list is drawn
|
||||||
// cached, a stale flag leaves a conversation muted past its deadline.
|
// rather than cached, a stale flag leaves a conversation muted past
|
||||||
|
// its deadline.
|
||||||
refreshSnoozed()
|
refreshSnoozed()
|
||||||
val fetched =
|
val fetched =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching { current.roster() }
|
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 ->
|
.onSuccess { list ->
|
||||||
list.forEach { buddy ->
|
list.forEach { buddy ->
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -602,9 +605,9 @@ class MessengerViewModel(
|
|||||||
val added =
|
val added =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
// Off-server roster (P1): the add is this device's to make, the
|
// Off-server roster (P1): the add is this device's to
|
||||||
// relay only resolved the handle, and that lookup was
|
// make, the relay only resolved the handle, and that
|
||||||
// transient.
|
// lookup was transient.
|
||||||
if (current.rosterOffServer()) {
|
if (current.rosterOffServer()) {
|
||||||
val found =
|
val found =
|
||||||
signedIn()?.lookedUp
|
signedIn()?.lookedUp
|
||||||
@@ -1367,7 +1370,8 @@ class MessengerViewModel(
|
|||||||
val made =
|
val made =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
// A fresh X25519 key, not one derived from the passphrase.
|
// A fresh X25519 key, not one derived from the
|
||||||
|
// passphrase.
|
||||||
val raw =
|
val raw =
|
||||||
FilesIdentity.rawFromPkcs8(X25519.generate().private.encoded)
|
FilesIdentity.rawFromPkcs8(X25519.generate().private.encoded)
|
||||||
store.saveFilesIdentityRecord(FilesIdentity.protect(raw, passphrase))
|
store.saveFilesIdentityRecord(FilesIdentity.protect(raw, passphrase))
|
||||||
@@ -1797,8 +1801,8 @@ class MessengerViewModel(
|
|||||||
|
|
||||||
fun removeBuddy(userId: Long) =
|
fun removeBuddy(userId: Long) =
|
||||||
act { current ->
|
act { current ->
|
||||||
// The sealed copy first: in off-server mode it IS the roster, and in
|
// The sealed copy first: in off-server mode it IS the roster, and
|
||||||
// mirror mode a remove must not resurrect on the next fetch.
|
// in mirror mode a remove must not resurrect on the next fetch.
|
||||||
current.rosterRemoveLocal(userId)
|
current.rosterRemoveLocal(userId)
|
||||||
if (!current.rosterOffServer()) current.api.removeBuddy(userId)
|
if (!current.rosterOffServer()) current.api.removeBuddy(userId)
|
||||||
current.roster()
|
current.roster()
|
||||||
@@ -1989,8 +1993,9 @@ class MessengerViewModel(
|
|||||||
openChat =
|
openChat =
|
||||||
state.openChat?.copy(
|
state.openChat?.copy(
|
||||||
disappearSeconds = current.conversations.disappearSeconds(peerId),
|
disappearSeconds = current.conversations.disappearSeconds(peerId),
|
||||||
// Applying the timer expires anything already past it, so
|
// Applying the timer expires anything already past
|
||||||
// the thread has to be re-read rather than left on screen.
|
// it, so the thread has to be re-read rather than
|
||||||
|
// left on screen.
|
||||||
lines = current.history(chat.threadId),
|
lines = current.history(chat.threadId),
|
||||||
),
|
),
|
||||||
error = done.exceptionOrNull()?.let(::describe),
|
error = done.exceptionOrNull()?.let(::describe),
|
||||||
@@ -2254,12 +2259,12 @@ class MessengerViewModel(
|
|||||||
CallManager(
|
CallManager(
|
||||||
context = getApplication(),
|
context = getApplication(),
|
||||||
signal = { peerId, signal ->
|
signal = { peerId, signal ->
|
||||||
// Fire and forget on the IO pool: signalling must not block the
|
// Fire and forget on the IO pool: signalling must not block
|
||||||
// WebRTC callback thread it is raised from.
|
// the WebRTC callback thread it is raised from.
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
runCatching { session?.sendCallSignal(peerId, signal) }
|
runCatching { session?.sendCallSignal(peerId, signal) }
|
||||||
// This was `runCatching { }` and nothing else, so every
|
// This was `runCatching { }` and nothing else, so
|
||||||
// failure here vanished.
|
// every failure here vanished.
|
||||||
.onFailure { failure ->
|
.onFailure { failure ->
|
||||||
if (failure is IdentityChangedException) {
|
if (failure is IdentityChangedException) {
|
||||||
calls?.hangUp(notifyPeer = false)
|
calls?.hangUp(notifyPeer = false)
|
||||||
@@ -2297,10 +2302,11 @@ class MessengerViewModel(
|
|||||||
when (state) {
|
when (state) {
|
||||||
CallManager.State.ACTIVE -> callWasConnected = true
|
CallManager.State.ACTIVE -> callWasConnected = true
|
||||||
|
|
||||||
// A new call starting, outgoing OR incoming. placeCall
|
// A new call starting, outgoing OR incoming.
|
||||||
// resets this too, but an incoming call never went
|
// placeCall resets this too, but an incoming call
|
||||||
// through placeCall, and inheriting the previous call's
|
// never went through placeCall, and inheriting the
|
||||||
// `true` filed a missed call as answered.
|
// previous call's `true` filed a missed call as
|
||||||
|
// answered.
|
||||||
CallManager.State.DIALLING,
|
CallManager.State.DIALLING,
|
||||||
CallManager.State.RINGING,
|
CallManager.State.RINGING,
|
||||||
-> callWasConnected = false
|
-> callWasConnected = false
|
||||||
@@ -2824,8 +2830,8 @@ class MessengerViewModel(
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching { current.forwardMessage(text, toPeerUserId, toGroup) }
|
runCatching { current.forwardMessage(text, toPeerUserId, toGroup) }
|
||||||
}
|
}
|
||||||
// The forward lands in ANOTHER thread, so the chat on screen is not
|
// The forward lands in ANOTHER thread, so the chat on screen is not the
|
||||||
// the one that changed.
|
// one that changed.
|
||||||
update {
|
update {
|
||||||
it.copy(
|
it.copy(
|
||||||
openChat =
|
openChat =
|
||||||
@@ -2843,9 +2849,8 @@ class MessengerViewModel(
|
|||||||
) = viewModelScope.launch {
|
) = viewModelScope.launch {
|
||||||
val current = session ?: return@launch
|
val current = session ?: return@launch
|
||||||
val mid = line.id ?: return@launch
|
val mid = line.id ?: return@launch
|
||||||
// Whose message it is, which is what the other side needs to find
|
// Whose message it is, which is what the other side needs to find it.
|
||||||
// it: our own id for something we sent, theirs for something they
|
// our own id for something we sent, theirs for something they did.
|
||||||
// did.
|
|
||||||
val author =
|
val author =
|
||||||
if (line.mine) {
|
if (line.mine) {
|
||||||
current.userId
|
current.userId
|
||||||
@@ -2946,7 +2951,8 @@ class MessengerViewModel(
|
|||||||
"Report sent. Whoever runs this server will see it; you will not be " +
|
"Report sent. Whoever runs this server will see it; you will not be " +
|
||||||
"told what they do."
|
"told what they do."
|
||||||
} else {
|
} 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."
|
"That report could not be sent. This server may not take reports."
|
||||||
}
|
}
|
||||||
update { it.copy(error = message) }
|
update { it.copy(error = message) }
|
||||||
@@ -3317,8 +3323,8 @@ class MessengerViewModel(
|
|||||||
}
|
}
|
||||||
result
|
result
|
||||||
.onSuccess { sent ->
|
.onSuccess { sent ->
|
||||||
// The badge has to catch up on SUCCESS too, and this is the case
|
// The badge has to catch up on SUCCESS too, and this is the
|
||||||
// that actually matters.
|
// case that actually matters.
|
||||||
val peer = chat.peerId
|
val peer = chat.peerId
|
||||||
update {
|
update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -3344,9 +3350,9 @@ class MessengerViewModel(
|
|||||||
it.copy(
|
it.copy(
|
||||||
error = describe(failure),
|
error = describe(failure),
|
||||||
// A refusal changes what we believe about them, so the
|
// A refusal changes what we believe about them, so the
|
||||||
// badge has to catch up in the same breath, otherwise the
|
// badge has to catch up in the same breath, otherwise
|
||||||
// header still says "pinned" while the send just failed
|
// the header still says "pinned" while the send just
|
||||||
// because it is not.
|
// failed because it is not.
|
||||||
openChat =
|
openChat =
|
||||||
chat.peerId?.let { peer ->
|
chat.peerId?.let { peer ->
|
||||||
it.openChat?.copy(trust = current.conversations.trust.state(peer))
|
it.openChat?.copy(trust = current.conversations.trust.state(peer))
|
||||||
@@ -3366,14 +3372,14 @@ class MessengerViewModel(
|
|||||||
installCallSignalHandler()
|
installCallSignalHandler()
|
||||||
polling =
|
polling =
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Make sure the relay holds a digest for this account before the
|
// Make sure the relay holds a digest for this account before
|
||||||
// first poll.
|
// the first poll.
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching { session?.conversations?.ensureDeliveryKey() }
|
runCatching { session?.conversations?.ensureDeliveryKey() }
|
||||||
}
|
}
|
||||||
while (true) {
|
while (true) {
|
||||||
// An offer the background service consumed while the app was
|
// An offer the background service consumed while the app
|
||||||
// off screen.
|
// was off screen.
|
||||||
PendingIncomingCall.take()?.let { parked ->
|
PendingIncomingCall.take()?.let { parked ->
|
||||||
// Stop the ring.
|
// Stop the ring.
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -3389,14 +3395,15 @@ class MessengerViewModel(
|
|||||||
}
|
}
|
||||||
val current = session
|
val current = session
|
||||||
if (current != null) {
|
if (current != null) {
|
||||||
// A story arriving must appear without the user pulling to
|
// A story arriving must appear without the user pulling
|
||||||
// refresh; it is filed by `receive()` and read back here.
|
// to refresh; it is filed by `receive()` and read back
|
||||||
|
// here.
|
||||||
val received =
|
val received =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val got = runCatching { current.receive() }
|
val got = runCatching { current.receive() }
|
||||||
// Cached plaintext for anything nothing refers to any
|
// Cached plaintext for anything nothing refers
|
||||||
// more, an expired message's photo, a story past its
|
// to any more, an expired message's photo, a
|
||||||
// lifetime.
|
// story past its lifetime.
|
||||||
runCatching {
|
runCatching {
|
||||||
current.purgeOrphanedAttachments(getApplication<Application>().cacheDir)
|
current.purgeOrphanedAttachments(getApplication<Application>().cacheDir)
|
||||||
}
|
}
|
||||||
@@ -3429,15 +3436,16 @@ class MessengerViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Read back every tick rather than only when a thread was
|
// Read back every tick rather than only when a thread
|
||||||
// touched: a story files itself outside the thread map, so
|
// was touched: a story files itself outside the thread
|
||||||
// `threads` is empty for a poll that brought one in.
|
// map, so `threads` is empty for a poll that brought
|
||||||
|
// one in.
|
||||||
val polled =
|
val polled =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching { current.stories() }
|
runCatching { current.stories() }
|
||||||
}
|
}
|
||||||
// Skip THIS tick's update on failure rather than leaving
|
// Skip THIS tick's update on failure rather than
|
||||||
// the loop, the next poll may well succeed.
|
// leaving the loop, the next poll may well succeed.
|
||||||
polled.getOrNull()?.let { live ->
|
polled.getOrNull()?.let { live ->
|
||||||
update { state ->
|
update { state ->
|
||||||
if (state.stories.map { it.id } == live.map { it.id }) {
|
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
|
// A picture arriving is a control message: it touches
|
||||||
// thread, so it needs its own signal.
|
// no thread, so it needs its own signal.
|
||||||
if (current.groupNamesUpdated) {
|
if (current.groupNamesUpdated) {
|
||||||
current.groupNamesUpdated = false
|
current.groupNamesUpdated = false
|
||||||
withContext(Dispatchers.IO) { runCatching { current.namedGroups() } }
|
withContext(Dispatchers.IO) { runCatching { current.namedGroups() } }
|
||||||
@@ -3484,8 +3492,8 @@ class MessengerViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (threads.isNotEmpty()) {
|
if (threads.isNotEmpty()) {
|
||||||
// Whoever just wrote may not be on the list at all, see
|
// Whoever just wrote may not be on the list at all,
|
||||||
// adoptStrangers.
|
// see adoptStrangers.
|
||||||
adoptStrangers(threads)
|
adoptStrangers(threads)
|
||||||
update { state ->
|
update { state ->
|
||||||
val open = state.openChat
|
val open = state.openChat
|
||||||
@@ -3498,8 +3506,8 @@ class MessengerViewModel(
|
|||||||
it
|
it
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// A thread that is already open is being read,
|
// A thread that is already open is being
|
||||||
// so it does not also become unread.
|
// read, so it does not also become unread.
|
||||||
unread = state.unread + threads - setOfNotNull(open?.threadId),
|
unread = state.unread + threads - setOfNotNull(open?.threadId),
|
||||||
// Refreshed here, not only at sign-in.
|
// Refreshed here, not only at sign-in.
|
||||||
remoteAddresses = current.remoteAddresses(),
|
remoteAddresses = current.remoteAddresses(),
|
||||||
@@ -3507,9 +3515,9 @@ class MessengerViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A call being set up lives or dies on signalling latency, the
|
// A call being set up lives or dies on signalling latency,
|
||||||
// 5 s cadence made every answer feel geological, and stacked
|
// the 5 s cadence made every answer feel geological, and
|
||||||
// with the same cadence on the other end.
|
// stacked with the same cadence on the other end.
|
||||||
val settingUp =
|
val settingUp =
|
||||||
when (calls?.state) {
|
when (calls?.state) {
|
||||||
CallManager.State.DIALLING,
|
CallManager.State.DIALLING,
|
||||||
@@ -3692,9 +3700,9 @@ internal fun describeFailure(failure: Throwable): String =
|
|||||||
"Too many attempts. Wait a few minutes."
|
"Too many attempts. Wait a few minutes."
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not the underlying message: at this layer it is a Java networking string
|
// Not the underlying message: at this layer it is a Java networking
|
||||||
// ("Failed to connect to /10.0.2.2:443"), which names an address the person
|
// string ("Failed to connect to /10.0.2.2:443"), which names an address
|
||||||
// never typed.
|
// the person never typed.
|
||||||
is AccountsClient.Failure.Transport -> {
|
is AccountsClient.Failure.Transport -> {
|
||||||
"Couldn't reach that server. Check the address and your connection."
|
"Couldn't reach that server. Check the address and your connection."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,55 +6,49 @@ import java.io.ByteArrayOutputStream
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Strips metadata from an image on its way out, whichever door it left by.
|
* 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 {
|
object MetadataScrub {
|
||||||
|
|
||||||
enum class ImageKind { JPEG, PNG, HEIC, TIFF, WEBP, GIF }
|
enum class ImageKind { JPEG, PNG, HEIC, TIFF, WEBP, GIF }
|
||||||
|
|
||||||
/** What happened, so the caller can say so rather than guess. */
|
/** 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.
|
// ByteArray in a data class needs these, or equality compares identity.
|
||||||
override fun equals(other: Any?): Boolean =
|
override fun equals(other: Any?): Boolean =
|
||||||
this === other || (other is Result &&
|
this === other || (
|
||||||
bytes.contentEquals(other.bytes) && strippedKind == other.strippedKind)
|
other is Result &&
|
||||||
|
bytes.contentEquals(other.bytes) && strippedKind == other.strippedKind
|
||||||
|
)
|
||||||
|
|
||||||
override fun hashCode(): Int = 31 * bytes.contentHashCode() + (strippedKind?.hashCode() ?: 0)
|
override fun hashCode(): Int = 31 * bytes.contentHashCode() + (strippedKind?.hashCode() ?: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pure, and the reason this is testable without a device. */
|
/** Pure, and the reason this is testable without a device. */
|
||||||
fun kind(of: ByteArray): ImageKind? {
|
fun kind(of: ByteArray): ImageKind? {
|
||||||
fun at(i: Int): Int? = if (i < of.size) of[i].toInt() and 0xFF else null
|
fun at(i: Int): Int? = if (i < of.size) of[i].toInt() and 0xFF else null
|
||||||
fun matches(bytes: List<Int>, offset: Int = 0): Boolean =
|
|
||||||
bytes.withIndex().all { (i, b) -> at(offset + i) == b }
|
fun matches(
|
||||||
|
bytes: List<Int>,
|
||||||
|
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(0xFF, 0xD8, 0xFF))) return ImageKind.JPEG
|
||||||
if (matches(listOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))) return ImageKind.PNG
|
if (matches(listOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))) return ImageKind.PNG
|
||||||
if (matches(listOf(0x47, 0x49, 0x46, 0x38))) return ImageKind.GIF
|
if (matches(listOf(0x47, 0x49, 0x46, 0x38))) return ImageKind.GIF
|
||||||
// TIFF, either byte order. ⚠️ Also the container many RAW files use,
|
// TIFF, either byte order.
|
||||||
// which is exactly why it should be recognised rather than sent raw.
|
|
||||||
if (matches(listOf(0x49, 0x49, 0x2A, 0x00)) ||
|
if (matches(listOf(0x49, 0x49, 0x2A, 0x00)) ||
|
||||||
matches(listOf(0x4D, 0x4D, 0x00, 0x2A))) return ImageKind.TIFF
|
matches(listOf(0x4D, 0x4D, 0x00, 0x2A))
|
||||||
// RIFF….WEBP — the size field sits between the two markers.
|
) {
|
||||||
|
return ImageKind.TIFF
|
||||||
|
}
|
||||||
|
// RIFF….WEBP, the size field sits between the two markers.
|
||||||
if (matches(listOf(0x52, 0x49, 0x46, 0x46)) &&
|
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.
|
// ISO-BMFF: "ftyp" at offset 4, then a HEIF brand.
|
||||||
if (matches(listOf(0x66, 0x74, 0x79, 0x70), 4)) {
|
if (matches(listOf(0x66, 0x74, 0x79, 0x70), 4)) {
|
||||||
val brand = (8..11).mapNotNull { at(it) }.map { it.toChar() }.joinToString("")
|
val brand = (8..11).mapNotNull { at(it) }.map { it.toChar() }.joinToString("")
|
||||||
@@ -68,30 +62,20 @@ object MetadataScrub {
|
|||||||
/**
|
/**
|
||||||
* Re-encode an image so nothing but pixels survives; pass anything else
|
* Re-encode an image so nothing but pixels survives; pass anything else
|
||||||
* through unchanged.
|
* 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 {
|
fun scrub(data: ByteArray): Result {
|
||||||
val kind = kind(data) ?: return Result(data, null)
|
val kind = kind(data) ?: return Result(data, null)
|
||||||
val raw = runCatching { BitmapFactory.decodeByteArray(data, 0, data.size) }.getOrNull()
|
val raw =
|
||||||
|
runCatching { BitmapFactory.decodeByteArray(data, 0, data.size) }.getOrNull()
|
||||||
?: return Result(data, null)
|
?: return Result(data, null)
|
||||||
// 🔴 This function's whole job is to remove the EXIF block — which
|
// This function's whole job is to remove the EXIF block, which holds
|
||||||
// holds the GPS coordinates AND the tag saying which way is up. Turning
|
// the GPS coordinates AND the tag saying which way is up.
|
||||||
// the pixels first is what keeps the second from being collateral
|
|
||||||
// damage of removing the first. See ImagePrep.orientedFrom.
|
|
||||||
val bitmap = ImagePrep.orientedFrom(data, raw)
|
val bitmap = ImagePrep.orientedFrom(data, raw)
|
||||||
val out = ByteArrayOutputStream()
|
val out = ByteArrayOutputStream()
|
||||||
// PNG where a lossy pass would be a second surprise; JPEG otherwise, at
|
// PNG where a lossy pass would be a second surprise; JPEG otherwise, at
|
||||||
// a quality high enough not to be visible.
|
// a quality high enough not to be visible.
|
||||||
val format = when (kind) {
|
val format =
|
||||||
|
when (kind) {
|
||||||
ImageKind.PNG, ImageKind.GIF, ImageKind.TIFF, ImageKind.WEBP -> Bitmap.CompressFormat.PNG
|
ImageKind.PNG, ImageKind.GIF, ImageKind.TIFF, ImageKind.WEBP -> Bitmap.CompressFormat.PNG
|
||||||
ImageKind.JPEG, ImageKind.HEIC -> Bitmap.CompressFormat.JPEG
|
ImageKind.JPEG, ImageKind.HEIC -> Bitmap.CompressFormat.JPEG
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,9 +146,9 @@ private fun EmojiSection(
|
|||||||
Modifier
|
Modifier
|
||||||
.clip(RoundedCornerShape(Radius.small))
|
.clip(RoundedCornerShape(Radius.small))
|
||||||
.clickable { onPick(e) }
|
.clickable { onPick(e) }
|
||||||
// Scoped by section, as on iOS: the same emoji can sit in
|
// Scoped by section, as on iOS: the same emoji can sit
|
||||||
// Recent AND its own group, and a tag on the glyph alone is
|
// in Recent AND its own group, and a tag on the glyph
|
||||||
// ambiguous to a test.
|
// alone is ambiguous to a test.
|
||||||
.testTag("emoji-$title-$e"),
|
.testTag("emoji-$title-$e"),
|
||||||
) {
|
) {
|
||||||
Text(e, fontSize = 24.sp, modifier = Modifier.padding(Space.xs))
|
Text(e, fontSize = 24.sp, modifier = Modifier.padding(Space.xs))
|
||||||
|
|||||||
@@ -411,9 +411,9 @@ class Session(
|
|||||||
|
|
||||||
val inUse =
|
val inUse =
|
||||||
try {
|
try {
|
||||||
// The SAME transformation `attachmentBytes` applies when it names
|
// The SAME transformation `attachmentBytes` applies when it
|
||||||
// the file, comparing raw ids against mangled filenames would find
|
// names the file, comparing raw ids against mangled filenames
|
||||||
// no match and delete everything.
|
// would find no match and delete everything.
|
||||||
vault.attachmentIdsInUse().map { it.replace('/', '_') }.toSet()
|
vault.attachmentIdsInUse().map { it.replace('/', '_') }.toSet()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
return 0
|
return 0
|
||||||
@@ -917,9 +917,9 @@ class Session(
|
|||||||
.put("to", peerUserId)
|
.put("to", peerUserId)
|
||||||
.put("text", text)
|
.put("text", text)
|
||||||
.put("at", at)
|
.put("at", at)
|
||||||
// Present only for group entries: the bubble to restate lives in
|
// Present only for group entries: the bubble to restate lives
|
||||||
// the GROUP thread, not in a 1:1 thread with this member, which may
|
// in the GROUP thread, not in a 1:1 thread with this member,
|
||||||
// not even exist.
|
// which may not even exist.
|
||||||
.also { if (thread != null) it.put("thread", thread) },
|
.also { if (thread != null) it.put("thread", thread) },
|
||||||
)
|
)
|
||||||
vault.saveOutbox(pending)
|
vault.saveOutbox(pending)
|
||||||
@@ -1549,7 +1549,8 @@ class Session(
|
|||||||
val live =
|
val live =
|
||||||
all
|
all
|
||||||
.filter { it.isActive(now) }
|
.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) }
|
.filter { it.mine || !storiesMuted(it.authorId) }
|
||||||
// Written back so expired bytes leave the device rather than lingering
|
// Written back so expired bytes leave the device rather than lingering
|
||||||
// until the next post.
|
// until the next post.
|
||||||
@@ -1668,9 +1669,9 @@ class Session(
|
|||||||
caption = caption.orEmpty(),
|
caption = caption.orEmpty(),
|
||||||
postedAt = System.currentTimeMillis(),
|
postedAt = System.currentTimeMillis(),
|
||||||
seen = true,
|
seen = true,
|
||||||
// The poster sees the same gate their contacts will, rather
|
// The poster sees the same gate their contacts will,
|
||||||
// than a story that looks different to the person who sent
|
// rather than a story that looks different to the
|
||||||
// it.
|
// person who sent it.
|
||||||
streamUrl = streamUrl,
|
streamUrl = streamUrl,
|
||||||
),
|
),
|
||||||
now,
|
now,
|
||||||
|
|||||||
@@ -74,16 +74,17 @@ object StoryMedia {
|
|||||||
retriever
|
retriever
|
||||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||||
?.toIntOrNull() ?: 0
|
?.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) {
|
if (rotation == 90 || rotation == 270) {
|
||||||
val t = w
|
val t = w
|
||||||
w = h
|
w = h
|
||||||
h = t
|
h = t
|
||||||
}
|
}
|
||||||
// The rotation is carried out of here now, not just used to
|
// The rotation is carried out of here now, not just used to
|
||||||
// swap the dimensions: rebuilding the container to strip its
|
// swap the dimensions: rebuilding the container to strip
|
||||||
// metadata drops the orientation with everything else, and a
|
// its metadata drops the orientation with everything else,
|
||||||
// portrait story would play on its side.
|
// and a portrait story would play on its side.
|
||||||
Meta(durationMs / 1000.0, w, h, rotation)
|
Meta(durationMs / 1000.0, w, h, rotation)
|
||||||
}.getOrNull().also { runCatching { retriever.release() } }
|
}.getOrNull().also { runCatching { retriever.release() } }
|
||||||
|
|
||||||
|
|||||||
@@ -180,8 +180,8 @@ fun MessengerTheme(content: @Composable () -> Unit) {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
lightColorScheme(
|
lightColorScheme(
|
||||||
// The kit's #2298FF on white is about 3.3:1, fine for a bold
|
// The kit's #2298FF on white is about 3.3:1, fine for a
|
||||||
// button label, not for small text.
|
// bold button label, not for small text.
|
||||||
primary = Palette.Accent,
|
primary = Palette.Accent,
|
||||||
onPrimary = Color.White,
|
onPrimary = Color.White,
|
||||||
secondary = Palette.AccentBright,
|
secondary = Palette.AccentBright,
|
||||||
|
|||||||
@@ -165,10 +165,10 @@ private fun ColumnScope.PhraseCard(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(Radius.small))
|
.clip(RoundedCornerShape(Radius.small))
|
||||||
// `Mist`, not `SurfaceAlt`: the card is already SurfaceAlt, and
|
// `Mist`, not `SurfaceAlt`: the card is already SurfaceAlt,
|
||||||
// two identical greys would leave the words looking like body
|
// and two identical greys would leave the words looking
|
||||||
// text rather than the one thing on this screen worth copying
|
// like body text rather than the one thing on this screen
|
||||||
// onto paper.
|
// worth copying onto paper.
|
||||||
.background(Palette.Mist)
|
.background(Palette.Mist)
|
||||||
.padding(Space.sm)
|
.padding(Space.sm)
|
||||||
.testTag("wallet-phrase"),
|
.testTag("wallet-phrase"),
|
||||||
|
|||||||
@@ -253,8 +253,8 @@ class OutboxTest {
|
|||||||
org.json
|
org.json
|
||||||
.JSONObject()
|
.JSONObject()
|
||||||
.put("id", opk.id)
|
.put("id", opk.id)
|
||||||
// `Bytes` is internal to :engine, so base64 by hand rather than
|
// `Bytes` is internal to :engine, so base64 by hand rather
|
||||||
// widening its visibility for a test.
|
// than widening its visibility for a test.
|
||||||
.put(
|
.put(
|
||||||
"pub",
|
"pub",
|
||||||
android.util.Base64.encodeToString(opk.publicKey, android.util.Base64.NO_WRAP),
|
android.util.Base64.encodeToString(opk.publicKey, android.util.Base64.NO_WRAP),
|
||||||
|
|||||||
@@ -7,21 +7,8 @@ import java.io.File
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A photo must arrive the way up it was taken.
|
* 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 {
|
class PhotoOrientationTest {
|
||||||
|
|
||||||
private fun source(path: String): String {
|
private fun source(path: String): String {
|
||||||
val file = File("src/main/kotlin/io/flamenet/messenger/app/$path")
|
val file = File("src/main/kotlin/io/flamenet/messenger/app/$path")
|
||||||
assertTrue("missing $path", file.exists())
|
assertTrue("missing $path", file.exists())
|
||||||
@@ -32,58 +19,74 @@ class PhotoOrientationTest {
|
|||||||
@Test
|
@Test
|
||||||
fun preparingAPhotoAppliesTheExifTag() {
|
fun preparingAPhotoAppliesTheExifTag() {
|
||||||
val prep = source("ImagePrep.kt")
|
val prep = source("ImagePrep.kt")
|
||||||
assertTrue("prepare() must orient before encoding",
|
assertTrue(
|
||||||
prep.contains("orientedFrom(source, decodeScaled(source, bounds, maxSide)"))
|
"prepare() must orient before encoding",
|
||||||
assertTrue("must read the tag rather than guess",
|
prep.contains("orientedFrom(source, decodeScaled(source, bounds, maxSide)"),
|
||||||
prep.contains("ExifInterface.TAG_ORIENTATION"))
|
)
|
||||||
|
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
|
@Test
|
||||||
fun scrubbingAppliesTheExifTagBeforeRemovingIt() {
|
fun scrubbingAppliesTheExifTagBeforeRemovingIt() {
|
||||||
assertTrue("scrub() must orient before re-encoding",
|
assertTrue(
|
||||||
source("MetadataScrub.kt").contains("ImagePrep.orientedFrom(data, raw)"))
|
"scrub() must orient before re-encoding",
|
||||||
|
source("MetadataScrub.kt").contains("ImagePrep.orientedFrom(data, raw)"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Images received from a peer carry the tag too. */
|
/** Images received from a peer carry the tag too. */
|
||||||
@Test
|
@Test
|
||||||
fun renderingAReceivedImageAppliesTheTag() {
|
fun renderingAReceivedImageAppliesTheTag() {
|
||||||
assertTrue("decodeBounded must orient what it decodes",
|
assertTrue(
|
||||||
source("ImagePrep.kt").contains("?.let { orientedFrom(source, it) }"))
|
"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
|
* The mirrored tags are rare but real, a front camera writes them, and a
|
||||||
* a selfie coming back flipped is the same class of bug as one coming back
|
* selfie coming back flipped is the same class of bug as one coming back
|
||||||
* sideways.
|
* sideways.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun everyOrientationTagIsHandled() {
|
fun everyOrientationTagIsHandled() {
|
||||||
val prep = source("ImagePrep.kt")
|
val prep = source("ImagePrep.kt")
|
||||||
for (tag in listOf(
|
for (tag in listOf(
|
||||||
"ORIENTATION_ROTATE_90", "ORIENTATION_ROTATE_180", "ORIENTATION_ROTATE_270",
|
"ORIENTATION_ROTATE_90",
|
||||||
"ORIENTATION_FLIP_HORIZONTAL", "ORIENTATION_FLIP_VERTICAL",
|
"ORIENTATION_ROTATE_180",
|
||||||
"ORIENTATION_TRANSPOSE", "ORIENTATION_TRANSVERSE",
|
"ORIENTATION_ROTATE_270",
|
||||||
|
"ORIENTATION_FLIP_HORIZONTAL",
|
||||||
|
"ORIENTATION_FLIP_VERTICAL",
|
||||||
|
"ORIENTATION_TRANSPOSE",
|
||||||
|
"ORIENTATION_TRANSVERSE",
|
||||||
)) {
|
)) {
|
||||||
assertTrue("$tag is not handled", prep.contains(tag))
|
assertTrue("$tag is not handled", prep.contains(tag))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔴 `windowSoftInputMode="adjustResize"` is a no-op at targetSdk 35:
|
* `windowSoftInputMode="adjustResize"` is a no-op at targetSdk 35: Android
|
||||||
* Android 15 forces edge-to-edge, and an edge-to-edge window does not
|
* 15 forces edge-to-edge, and an edge-to-edge window does not resize for
|
||||||
* resize for the keyboard. The insets have to be handled in Compose.
|
* the keyboard.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun theKeyboardDoesNotCoverWhatIsBeingTypedInto() {
|
fun theKeyboardDoesNotCoverWhatIsBeingTypedInto() {
|
||||||
val activity = source("MainActivity.kt")
|
val activity = source("MainActivity.kt")
|
||||||
assertTrue("the root must consume the IME inset",
|
assertTrue(
|
||||||
activity.contains("Modifier.fillMaxSize().imePadding()"))
|
"the root must consume the IME inset",
|
||||||
assertTrue("and import it", activity.contains(
|
activity.contains("Modifier.fillMaxSize().imePadding()"),
|
||||||
"import androidx.compose.foundation.layout.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
|
// The manifest line is kept, it still applies on older targets, but it
|
||||||
// it must never again be the only thing relied on.
|
// must never again be the only thing relied on.
|
||||||
val target = File("build.gradle.kts").readText()
|
val target = File("build.gradle.kts").readText()
|
||||||
assertTrue("this test's premise is targetSdk 35", target.contains("targetSdk = 35"))
|
assertTrue("this test's premise is targetSdk 35", target.contains("targetSdk = 35"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,16 +137,17 @@ class ScreenshotTest {
|
|||||||
at = 1_756_300_380_000,
|
at = 1_756_300_380_000,
|
||||||
delivery = Session.Delivery.DELIVERED,
|
delivery = Session.Delivery.DELIVERED,
|
||||||
),
|
),
|
||||||
// The state that used to be invisible: sent while they were signed
|
// The state that used to be invisible: sent while they were
|
||||||
// out, so no device took it.
|
// signed out, so no device took it.
|
||||||
Session.Line(
|
Session.Line(
|
||||||
"Are you around later?",
|
"Are you around later?",
|
||||||
mine = true,
|
mine = true,
|
||||||
at = 1_756_300_440_000,
|
at = 1_756_300_440_000,
|
||||||
delivery = Session.Delivery.PENDING,
|
delivery = Session.Delivery.PENDING,
|
||||||
),
|
),
|
||||||
// A sticker draws bare, no bubble, so this catches the case where
|
// A sticker draws bare, no bubble, so this catches the case
|
||||||
// it falls through to the text path and renders as an empty one.
|
// where it falls through to the text path and renders as an
|
||||||
|
// empty one.
|
||||||
Session.Line(
|
Session.Line(
|
||||||
"",
|
"",
|
||||||
mine = false,
|
mine = false,
|
||||||
@@ -255,9 +256,10 @@ class ScreenshotTest {
|
|||||||
),
|
),
|
||||||
buddiesLoaded = true,
|
buddiesLoaded = true,
|
||||||
groupsLoaded = true,
|
groupsLoaded = true,
|
||||||
// The row's right-hand column and second line only exist when there is
|
// The row's right-hand column and second line only exist when there
|
||||||
// a thread behind them, so the render has to have one or it photographs
|
// is a thread behind them, so the render has to have one or it
|
||||||
// the empty state and looks like the change never landed.
|
// photographs the empty state and looks like the change never
|
||||||
|
// landed.
|
||||||
previews =
|
previews =
|
||||||
mapOf(
|
mapOf(
|
||||||
32L to
|
32L to
|
||||||
|
|||||||
@@ -170,8 +170,8 @@ class AccountsClient(
|
|||||||
.post(body.toString().toRequestBody(JSON))
|
.post(body.toString().toRequestBody(JSON))
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.build(),
|
.build(),
|
||||||
// Only these two actually present a passphrase, so only these two can
|
// Only these two actually present a passphrase, so only these two
|
||||||
// meaningfully report one as wrong.
|
// can meaningfully report one as wrong.
|
||||||
carriesCredentials = path == "sessions" || path == "accounts",
|
carriesCredentials = path == "sessions" || path == "accounts",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -143,8 +143,8 @@ class Conversations(
|
|||||||
myUserId: Long,
|
myUserId: Long,
|
||||||
mentions: List<Int> = emptyList(),
|
mentions: List<Int> = emptyList(),
|
||||||
/**
|
/**
|
||||||
* Minted by the caller, not here, because the caller also
|
* Minted by the caller, not here, because the caller also writes the
|
||||||
* writes the sender's own copy and queues the retries.
|
* sender's own copy and queues the retries.
|
||||||
*/
|
*/
|
||||||
messageId: String? = null,
|
messageId: String? = null,
|
||||||
): Sent {
|
): Sent {
|
||||||
@@ -593,8 +593,8 @@ class Conversations(
|
|||||||
servedFnid = keys.fnid ?: servedFnid
|
servedFnid = keys.fnid ?: servedFnid
|
||||||
keys.fnid?.let { trust.rememberFnid(toUserId, it) }
|
keys.fnid?.let { trust.rememberFnid(toUserId, it) }
|
||||||
val full = keys.bundles.associateBy { it.deviceId }
|
val full = keys.bundles.associateBy { it.deviceId }
|
||||||
// Union: every device the directory knows, carrying a full bundle
|
// Union: every device the directory knows, carrying a full
|
||||||
// where the key route returned one.
|
// bundle where the key route returned one.
|
||||||
stubs.map { full[it.deviceId] ?: it }
|
stubs.map { full[it.deviceId] ?: it }
|
||||||
}
|
}
|
||||||
// The send path had no logging at all, and that is why a message that
|
// The send path had no logging at all, and that is why a message that
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ object LinkPreviewFetcher {
|
|||||||
.Builder()
|
.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.header("Accept", "text/html")
|
.header("Accept", "text/html")
|
||||||
// Identifies as a link preview rather than impersonating a browser.
|
// Identifies as a link preview rather than impersonating a
|
||||||
// a site that wants to refuse these should be able to.
|
// browser. a site that wants to refuse these should be able
|
||||||
|
// to.
|
||||||
.header("User-Agent", "FlamenetLinkPreview/1.0")
|
.header("User-Agent", "FlamenetLinkPreview/1.0")
|
||||||
.build()
|
.build()
|
||||||
http.newCall(request).execute().use { response ->
|
http.newCall(request).execute().use { response ->
|
||||||
|
|||||||
@@ -288,8 +288,8 @@ class LocalVault(
|
|||||||
.filter { it.parentId == parentId }
|
.filter { it.parentId == parentId }
|
||||||
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
|
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
|
||||||
.sortedWith(
|
.sortedWith(
|
||||||
// Folders first, then by name; unreadable rows last, where they
|
// Folders first, then by name; unreadable rows last, where
|
||||||
// are least in the way of the ones you can act on.
|
// they are least in the way of the ones you can act on.
|
||||||
compareByDescending<Entry> { it.isFolder }
|
compareByDescending<Entry> { it.isFolder }
|
||||||
.thenBy { it.name == null }
|
.thenBy { it.name == null }
|
||||||
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" },
|
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" },
|
||||||
|
|||||||
@@ -171,8 +171,8 @@ class MessengerApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Changing the password invalidates other credentials, which is the point
|
* Changing the password invalidates other credentials of changing it, so
|
||||||
* of changing it, so this device has to sign in again afterwards.
|
* this device has to sign in again afterwards.
|
||||||
*/
|
*/
|
||||||
fun updatePassword(password: String) {
|
fun updatePassword(password: String) {
|
||||||
raw("api/flamenet/v1/messenger/me", "PUT", JSONObject().put("password", password))
|
raw("api/flamenet/v1/messenger/me", "PUT", JSONObject().put("password", password))
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ class RelayAuth(
|
|||||||
)
|
)
|
||||||
return null
|
return null
|
||||||
} ?: run {
|
} ?: run {
|
||||||
// A non-2xx that is not 404 arrives here as null, not as a throw,
|
// A non-2xx that is not 404 arrives here as null, not as a
|
||||||
// so this branch is NOT unreachable, it was the quietest path of
|
// throw, so this branch is NOT unreachable, it was the quietest
|
||||||
// the three.
|
// path of the three.
|
||||||
warn(
|
warn(
|
||||||
"no token: /account/token answered a non-success " +
|
"no token: /account/token answered a non-success " +
|
||||||
"status; falling back to Basic, which /e2e/* refuses.",
|
"status; falling back to Basic, which /e2e/* refuses.",
|
||||||
|
|||||||
@@ -106,9 +106,9 @@ internal object Wire {
|
|||||||
.getDecoder()
|
.getDecoder()
|
||||||
.decode(text)
|
.decode(text)
|
||||||
|
|
||||||
// / The engine keeps its own `Bytes.sha256` `internal`, for the reason
|
// / The engine keeps its own `Bytes.sha256` `internal`, for the reason /
|
||||||
// / above: its public surface is the protocol, not its utilities. §21.4
|
// 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
|
// needs one here to hash a capability before uploading it, and SHA-256 has
|
||||||
// / one right answer, so a second call site cannot drift.
|
// / one right answer, so a second call site cannot drift.
|
||||||
fun sha256(data: ByteArray): ByteArray =
|
fun sha256(data: ByteArray): ByteArray =
|
||||||
java.security.MessageDigest
|
java.security.MessageDigest
|
||||||
|
|||||||
@@ -442,10 +442,10 @@ class Trust(
|
|||||||
when {
|
when {
|
||||||
pin.size > head.size -> false
|
pin.size > head.size -> false
|
||||||
|
|
||||||
// shrank
|
// shrank.
|
||||||
pin.size == head.size -> pin.root == head.root
|
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)
|
else -> consistent(pin.size, head.size, pin.root, head.root)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@@ -497,8 +497,8 @@ class Trust(
|
|||||||
val issuer =
|
val issuer =
|
||||||
when (val verdict = RelayIdentityPin.check(offered, vault.keyLogIssuerPin())) {
|
when (val verdict = RelayIdentityPin.check(offered, vault.keyLogIssuerPin())) {
|
||||||
is RelayIdentityPin.Verdict.NotPublished -> {
|
is RelayIdentityPin.Verdict.NotPublished -> {
|
||||||
// Nothing published and nothing pinned: a weaker deployment,
|
// Nothing published and nothing pinned: a weaker
|
||||||
// not an attack.
|
// deployment, not an attack.
|
||||||
vault.keyLogIssuerPin() ?: return
|
vault.keyLogIssuerPin() ?: return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ object UpdateCheck {
|
|||||||
|
|
||||||
val json = JSONObject(body)
|
val json = JSONObject(body)
|
||||||
val code = json.optInt("versionCode", 0)
|
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
|
if (code <= 0) return null
|
||||||
Release(
|
Release(
|
||||||
versionCode = code,
|
versionCode = code,
|
||||||
|
|||||||
@@ -89,8 +89,9 @@ class DeliveryTokenTest {
|
|||||||
|
|
||||||
"/e2e/delivery-tokens" in path -> {
|
"/e2e/delivery-tokens" in path -> {
|
||||||
val b = JSONObject(request.body.readUtf8())
|
val b = JSONObject(request.body.readUtf8())
|
||||||
// Revoke first, so a client cutting one contact off while
|
// Revoke first, so a client cutting one contact off
|
||||||
// topping another up in the same call keeps what it added.
|
// while topping another up in the same call keeps what
|
||||||
|
// it added.
|
||||||
b.optJSONArray("revoke")?.let { a ->
|
b.optJSONArray("revoke")?.let { a ->
|
||||||
(0 until a.length()).forEach { held.remove(a.getString(it)) }
|
(0 until a.length()).forEach { held.remove(a.getString(it)) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ class GossipHeadSentTest {
|
|||||||
override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse {
|
override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse {
|
||||||
val path = request.path.orEmpty()
|
val path = request.path.orEmpty()
|
||||||
return when {
|
return when {
|
||||||
// `contains`, not `startsWith`: every route is under the
|
// `contains`, not `startsWith`: every route is under
|
||||||
// REST mount, so the recorded path begins with that prefix
|
// the REST mount, so the recorded path begins with that
|
||||||
// rather than with `/e2e`.
|
// prefix rather than with `/e2e`.
|
||||||
"/e2e/log/" in path -> MockResponse().setResponseCode(404)
|
"/e2e/log/" in path -> MockResponse().setResponseCode(404)
|
||||||
|
|
||||||
path.contains("/e2e/messages") -> json("""{"ok":true}""")
|
path.contains("/e2e/messages") -> json("""{"ok":true}""")
|
||||||
|
|||||||
@@ -125,10 +125,10 @@ class MultiMailboxPollTest {
|
|||||||
relay = backupHost,
|
relay = backupHost,
|
||||||
username = "me",
|
username = "me",
|
||||||
appPassword = "pw",
|
appPassword = "pw",
|
||||||
// The record names a hostname and cannot carry a port; how this
|
// The record names a hostname and cannot carry a port; how
|
||||||
// device DIALS that host is a separate field, which is what lets a
|
// this device DIALS that host is a separate field, which is
|
||||||
// test, or a self-hoster on a nonstandard port, work without the
|
// what lets a test, or a self-hoster on a nonstandard port,
|
||||||
// record lying about its own name.
|
// work without the record lying about its own name.
|
||||||
baseUrl = backup.url("/").toString(),
|
baseUrl = backup.url("/").toString(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -103,9 +103,9 @@ object AccountKdf {
|
|||||||
params: Params = Params.PBKDF2_V1,
|
params: Params = Params.PBKDF2_V1,
|
||||||
): ByteArray =
|
): ByteArray =
|
||||||
SecureBytes.withSecret(master(passphrase, salt, params)) { ikm ->
|
SecureBytes.withSecret(master(passphrase, salt, params)) { ikm ->
|
||||||
// ios#39, the stretched master is an intermediate nobody needs after
|
// ios#39, the stretched master is an intermediate nobody needs
|
||||||
// the expansion, and it is the most valuable thing in this file: every
|
// after the expansion, and it is the most valuable thing in this
|
||||||
// other secret is derived from it.
|
// file: every other secret is derived from it.
|
||||||
Hkdf.derive(
|
Hkdf.derive(
|
||||||
ikm = ikm,
|
ikm = ikm,
|
||||||
salt = ByteArray(0),
|
salt = ByteArray(0),
|
||||||
|
|||||||
@@ -372,7 +372,8 @@ data class Content(
|
|||||||
): String =
|
): String =
|
||||||
assemble(
|
assemble(
|
||||||
kind = ref.kind,
|
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 =
|
||||||
caption?.takeIf { it.isNotEmpty() }
|
caption?.takeIf { it.isNotEmpty() }
|
||||||
?: "📎 "
|
?: "📎 "
|
||||||
@@ -781,8 +782,8 @@ data class Content(
|
|||||||
}
|
}
|
||||||
?: return Content(
|
?: return Content(
|
||||||
// The sender meant to say something; a message that
|
// The sender meant to say something; a message that
|
||||||
// silently vanishes is the worst way to meet a version this
|
// silently vanishes is the worst way to meet a version
|
||||||
// client does not know.
|
// this client does not know.
|
||||||
caption.ifEmpty { "[unsupported attachment]" },
|
caption.ifEmpty { "[unsupported attachment]" },
|
||||||
kind = kind,
|
kind = kind,
|
||||||
raw = json,
|
raw = json,
|
||||||
@@ -856,11 +857,11 @@ object Attachment {
|
|||||||
// Audio always; a story carries one when it is a video.
|
// Audio always; a story carries one when it is a video.
|
||||||
.also { if (duration > 0.0) it.put("dur", duration) }
|
.also { if (duration > 0.0) it.put("dur", duration) }
|
||||||
// Without this the vault round-trip erased every expiry.
|
// Without this the vault round-trip erased every expiry.
|
||||||
// saveStories persists refs through here, loadStories reads "exp"
|
// saveStories persists refs through here, loadStories reads
|
||||||
// back out of this very JSON, and a field never written reloads as
|
// "exp" back out of this very JSON, and a field never written
|
||||||
// 0, which renders as "no expiry" AND, because isActive treats 0 as
|
// reloads as 0, which renders as "no expiry" AND, because
|
||||||
// immortal, means an expired story's bytes were never dropped on
|
// isActive treats 0 as immortal, means an expired story's bytes
|
||||||
// write.
|
// were never dropped on write.
|
||||||
.also { if (expires > 0) it.put("exp", expires) }
|
.also { if (expires > 0) it.put("exp", expires) }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -80,10 +80,11 @@ class DeviceIdentity private constructor(
|
|||||||
Ed25519.fromSeed(Bytes.unb64(archive.getJSONObject("ikSig").getString("seed"))),
|
Ed25519.fromSeed(Bytes.unb64(archive.getJSONObject("ikSig").getString("seed"))),
|
||||||
signedPreKey = x(archive.getJSONObject("spk")),
|
signedPreKey = x(archive.getJSONObject("spk")),
|
||||||
signedPreKeyId = archive.getInt("spkId"),
|
signedPreKeyId = archive.getInt("spkId"),
|
||||||
// 0 means "written before this field existed", and the honest
|
// 0 means "written before this field existed", and the
|
||||||
// reading of an unknown age is "old", a device that rotates
|
// honest reading of an unknown age is "old", a device that
|
||||||
// once on first launch after an upgrade is harmless, one that
|
// rotates once on first launch after an upgrade is
|
||||||
// never rotates because it defaulted to `now` is not.
|
// harmless, one that never rotates because it defaulted to
|
||||||
|
// `now` is not.
|
||||||
signedPreKeyCreatedAt = archive.optLong("spkAt", 0),
|
signedPreKeyCreatedAt = archive.optLong("spkAt", 0),
|
||||||
)
|
)
|
||||||
identity.nextPreKeyId = archive.optInt("nextId", 1)
|
identity.nextPreKeyId = archive.optInt("nextId", 1)
|
||||||
|
|||||||
@@ -135,14 +135,14 @@ class E2ESession private constructor(
|
|||||||
|
|
||||||
val session =
|
val session =
|
||||||
E2ESession(
|
E2ESession(
|
||||||
// The seed of the PQ prekey the initiator used, so the first
|
// The seed of the PQ prekey the initiator used, so the
|
||||||
// inbound ratchet step can decapsulate to it.
|
// first inbound ratchet step can decapsulate to it.
|
||||||
ratchet = DoubleRatchet.responder(sharedSecret, signedPreKey, pq?.first),
|
ratchet = DoubleRatchet.responder(sharedSecret, signedPreKey, pq?.first),
|
||||||
associatedData =
|
associatedData =
|
||||||
X3DH.associatedData(theirIdentitySigning, identity.identitySigningPublic, pq?.second),
|
X3DH.associatedData(theirIdentitySigning, identity.identitySigningPublic, pq?.second),
|
||||||
pendingHandshake = null,
|
pendingHandshake = null,
|
||||||
// Honour what the initiator declared; this could never return
|
// Honour what the initiator declared; this could never
|
||||||
// 3.
|
// return 3.
|
||||||
version =
|
version =
|
||||||
when {
|
when {
|
||||||
pq == null -> 1
|
pq == null -> 1
|
||||||
@@ -161,9 +161,10 @@ class E2ESession private constructor(
|
|||||||
pendingHandshake = archive.optJSONObject("pending"),
|
pendingHandshake = archive.optJSONObject("pending"),
|
||||||
version = archive.optInt("version", 1),
|
version = archive.optInt("version", 1),
|
||||||
// Archived for the same reason `pending` is: it is part of the
|
// Archived for the same reason `pending` is: it is part of the
|
||||||
// handshake block, and a session restored without it would go on
|
// handshake block, and a session restored without it would go
|
||||||
// sending prekey envelopes with the post-quantum half missing,
|
// on sending prekey envelopes with the post-quantum half
|
||||||
// which the peer would answer by deriving a different key.
|
// missing, which the peer would answer by deriving a different
|
||||||
|
// key.
|
||||||
pq =
|
pq =
|
||||||
archive.optJSONObject("pq")?.let {
|
archive.optJSONObject("pq")?.let {
|
||||||
PQEnvelope(
|
PQEnvelope(
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ object SealedSender {
|
|||||||
* @param groupId with [groupName], tells this contact what a group is
|
* @param groupId with [groupName], tells this contact what a group is
|
||||||
* called.
|
* called.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* §8.9b, hand a member their tag and the group key.
|
* §8.9b, hand a member their tag and the group key.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -281,8 +281,8 @@ class Vault(
|
|||||||
username = it.getString("username"),
|
username = it.getString("username"),
|
||||||
appPassword = it.getString("appPassword"),
|
appPassword = it.getString("appPassword"),
|
||||||
userId = it.getLong("userId"),
|
userId = it.getLong("userId"),
|
||||||
// Optional read: a credential written before addresses existed is
|
// Optional read: a credential written before addresses existed
|
||||||
// still a valid credential, and must not fail to load.
|
// is still a valid credential, and must not fail to load.
|
||||||
fnid = it.optString("fnid").ifEmpty { null },
|
fnid = it.optString("fnid").ifEmpty { null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -349,8 +349,8 @@ class Vault(
|
|||||||
Trust(
|
Trust(
|
||||||
identities = (0 until a.length()).map { a.getString(it) },
|
identities = (0 until a.length()).map { a.getString(it) },
|
||||||
verified = o.optBoolean("verified", false),
|
verified = o.optBoolean("verified", false),
|
||||||
// `"null"` is rejected, and that is not paranoia, records written
|
// `"null"` is rejected, and that is not paranoia, records
|
||||||
// that way are already on phones.
|
// written that way are already on phones.
|
||||||
fnid = o.optString("fnid").takeIf { it.isNotEmpty() && it != "null" },
|
fnid = o.optString("fnid").takeIf { it.isNotEmpty() && it != "null" },
|
||||||
changed = o.optBoolean("changed", false),
|
changed = o.optBoolean("changed", false),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ class MailboxStateTest {
|
|||||||
|
|
||||||
private val storage = MapStorage()
|
private val storage = MapStorage()
|
||||||
|
|
||||||
// / Held so a SECOND vault can be opened over the same storage, which is
|
// / 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.
|
// 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 key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||||
private val vault = Vault(storage, key)
|
private val vault = Vault(storage, key)
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ class QrCodeRenderForDecodingTest {
|
|||||||
"flamenet:fnid:kx7q2m", // v1
|
"flamenet:fnid:kx7q2m", // v1
|
||||||
"flamenet:fnid:" + "a".repeat(52) + "@relay1.flamenet.io", // a real address
|
"flamenet:fnid:" + "a".repeat(52) + "@relay1.flamenet.io", // a real address
|
||||||
"flamenet:fnid:" + "b".repeat(52) + "@" + "long".repeat(8) + ".example.com",
|
"flamenet:fnid:" + "b".repeat(52) + "@" + "long".repeat(8) + ".example.com",
|
||||||
// Exactly full, version by version: data codewords minus the mode
|
// Exactly full, version by version: data codewords minus the
|
||||||
// nibble and character count.
|
// mode nibble and character count.
|
||||||
"x".repeat(14), // v1
|
"x".repeat(14), // v1
|
||||||
"x".repeat(26), // v2
|
"x".repeat(26), // v2
|
||||||
"x".repeat(42), // v3
|
"x".repeat(42), // v3
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ private fun handle(req: JSONObject): JSONObject =
|
|||||||
decrypt(req)
|
decrypt(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- the session layer, which is what an app actually uses ------------.
|
// --- the session layer, which is what an app actually uses
|
||||||
|
// ------------.
|
||||||
"session_device" -> {
|
"session_device" -> {
|
||||||
sessionDevice(req)
|
sessionDevice(req)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ class BridgeLineTest {
|
|||||||
"obfs4 cert=abc" to BridgeLineProblem.NoAddress,
|
"obfs4 cert=abc" to BridgeLineProblem.NoAddress,
|
||||||
line(address = "198.51.100.7:notaport") to BridgeLineProblem.BadPort("notaport"),
|
line(address = "198.51.100.7:notaport") to BridgeLineProblem.BadPort("notaport"),
|
||||||
"obfs4 198.51.100.7:4444 0123456789ABCDEF" to BridgeLineProblem.NoCert,
|
"obfs4 198.51.100.7:4444 0123456789ABCDEF" to BridgeLineProblem.NoCert,
|
||||||
// No spaces: fields split on whitespace, so a cert with a space is
|
// No spaces: fields split on whitespace, so a cert with a space
|
||||||
// truncated at the space and is a LENGTH problem, not an encoding
|
// is truncated at the space and is a LENGTH problem, not an
|
||||||
// one.
|
// encoding one.
|
||||||
line(cert = "!!!!not@@@@valid####base64$$$$") to BridgeLineProblem.CertNotReadable,
|
line(cert = "!!!!not@@@@valid####base64$$$$") to BridgeLineProblem.CertNotReadable,
|
||||||
)
|
)
|
||||||
for ((input, expected) in cases) {
|
for ((input, expected) in cases) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class HandshakeTest {
|
|||||||
try {
|
try {
|
||||||
return Obfs4Handshake.parseClientMessage(wire.copyOfRange(0, n), key(o), o.getLong("epoch_hour")) to n
|
return Obfs4Handshake.parseClientMessage(wire.copyOfRange(0, n), key(o), o.getLong("epoch_hour")) to n
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// keep reading
|
// keep reading.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|||||||
Reference in New Issue
Block a user