From 6dff1f0064fad48db1ada0d35a883c687e4d68ea Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 4 Oct 2026 11:28:18 -0400 Subject: [PATCH] Show a story's whole picture, and take it down or edit it from the feed The feed card cropped every picture to a 240dp band, which is how the top of a story went missing. A card now takes the picture's own shape, from two wide by one high to four wide by five high, never taller than 560dp (the iPhone's numbers), and fits the picture whole inside it with plain bars in the theme's colour beside a tall one or above and below a wide one. Videos and streams sit in the same frame. The composer draws the picked picture through the same frame, so it looks exactly as it will in friends' feeds before Post. The upload never cropped: pictures are only turned upright, scaled down whole and re-encoded. The strip on this phone draws initials, not pictures, so it has no thumbnail to crop. Under my own story, in one row: Edit caption (Edit words for a text story) and Take down. Take down asks first, in DESIGN item 25's words. The editor will not empty a story and offers Take down instead. A story whose words changed says Edited beside its time left. Co-Authored-By: Claude Opus 5.5 --- .../messenger/app/MessengerViewModel.kt | 50 +++ .../io/flamenet/messenger/app/StoriesFeed.kt | 290 ++++++++++++----- .../io/flamenet/messenger/app/StoryFrame.kt | 117 +++++++ .../flamenet/messenger/app/StoryFrameTest.kt | 118 +++++++ .../messenger/app/StoryScreensTest.kt | 296 ++++++++++++++++++ 5 files changed, 800 insertions(+), 71 deletions(-) create mode 100644 app/src/main/kotlin/io/flamenet/messenger/app/StoryFrame.kt create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/StoryFrameTest.kt create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/StoryScreensTest.kt diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 3113ab1..0b63606 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -3953,6 +3953,56 @@ class MessengerViewModel( fun closeStory() = update { it.copy(openStory = null) } + /** + * The seam for drawing a screen from a vault in a test, with no relay to + * sign in to: pictures already decrypted into the cache load as they do + * for a signed-in person. + */ + internal fun useSessionForTesting(s: Session) { + session = s + } + + /** + * Take one of my stories down for everyone it went to (§6.35). It leaves + * this phone at once; what the sends reached is said, not hidden. + */ + fun takeDownStory(story: Vault.Story) = + viewModelScope.launch { + val current = session ?: return@launch + val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty() + update { it.copy(busy = true, error = null) } + val done = withContext(Dispatchers.IO) { runCatching { current.takeDownStory(story, contacts) } } + val live = withContext(Dispatchers.IO) { runCatching { current.stories() } }.getOrNull() + update { state -> + state.copy( + busy = false, + openStory = state.openStory?.takeIf { it.id != story.id }, + stories = live ?: state.stories, + error = done.fold(onSuccess = StoryWords::takenDown, onFailure = ::describe), + ) + } + } + + /** New words for one of my stories (§6.15 for stories). Never empty: that is a take-down. */ + fun editStoryCaption( + story: Vault.Story, + caption: String, + ) = viewModelScope.launch { + val current = session ?: return@launch + if (caption.isBlank()) return@launch + val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty() + update { it.copy(busy = true, error = null) } + val done = withContext(Dispatchers.IO) { runCatching { current.editStoryCaption(story, caption, contacts) } } + val live = withContext(Dispatchers.IO) { runCatching { current.stories() } }.getOrNull() + update { state -> + state.copy( + busy = false, + stories = live ?: state.stories, + error = done.fold(onSuccess = StoryWords::edited, onFailure = ::describe), + ) + } + } + // -- Voice notes (§6.6) --------------------------------------------------. private var recording: VoiceNote.Recording? = null diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index e64be78..0ec22bf 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -298,8 +298,10 @@ private fun StoryCard( // The deadline, in the slot the kit gives a description, and // §6.23's count beside it: a card that silently stands for // five pictures is a card that loses four. + // §6.15: a story whose words changed says so. + val edited = if (story.editedAt != null) " · Edited" else "" Text( - if (setCount > 1) "$remaining · $setCount pictures" else remaining, + (if (setCount > 1) "$remaining · $setCount pictures" else remaining) + edited, fontSize = 12.sp, color = Palette.SecondaryText, modifier = Modifier.testTag("story-set-count-${story.id}"), @@ -321,39 +323,32 @@ private fun StoryCard( Spacer(Modifier.height(Space.lg)) - // The image. 375x320 in the kit; an aspect ratio here, because the kit - // is drawn on one canvas and this has to hold on every phone. - Box( - modifier = - Modifier - .fillMaxWidth() - .height(240.dp) - .clip(RoundedCornerShape(Radius.small)) - .background(Palette.SurfaceAlt), - contentAlignment = Alignment.Center, - ) { - // The card's own image, cropped to FILL the box, not the chat - // bubble's 260dp thumbnail, which floated small on a full-width - // backboard and read as a broken layout. §6.13, an embedded live - // stream. - val stream = story.streamUrl - val streamHost = story.streamHost - if (stream != null && streamHost != null && + // DESIGN item 27: the whole picture, never cut. The card takes the + // picture's own shape between a floor and a ceiling (`StoryFrame`), + // with plain bars past either. §6.13, an embedded live stream, sits in + // the frame of its poster. + val stream = story.streamUrl + val streamHost = story.streamHost + val ref = story.ref + when { + stream != null && streamHost != null && io.flamenet.e2e.StreamDisclosure - .acceptable(stream) - ) { - StoryStreamGate(stream, streamHost) - } else { - val ref = story.ref - when { - // Words alone, set large on a tinted band, the way every - // other messenger draws one. - ref == null -> TextStoryCard(story.caption) + .acceptable(stream) -> { + StoryPhotoFrame(null, ref?.width ?: 0, ref?.height ?: 0) { StoryStreamGate(stream, streamHost) } + } - ref.mime.startsWith("video/") -> StoryVideoCard(ref, model) + // Words alone, set large on a tinted band, the way every other + // messenger draws one. + ref == null -> { + TextStoryCard(story.caption) + } - else -> StoryImage(ref, model) - } + ref.mime.startsWith("video/") -> { + StoryPhotoFrame(null, ref.width, ref.height) { StoryVideoCard(ref, model) } + } + + else -> { + StoryImage(ref, model) } } @@ -396,7 +391,9 @@ private fun StoryActions( Text("$emoji $count", fontSize = 13.sp, color = Palette.SecondaryText) } } + Spacer(Modifier.height(Space.sm)) } + MyStoryControls(story, model) return } @@ -543,6 +540,161 @@ private fun StoryActions( } } +/** What the stories screens say about taking one down or changing its words. */ +object StoryWords { + /** DESIGN item 25, word for word, before a story comes down. */ + const val TAKE_DOWN_WARNING = + "This removes it from your friends' phones when their app gets the message. " + + "Anyone who already saw it may have kept a copy, and older versions of the app " + + "keep it until it expires." + + private const val TODAYS_AUDIENCE = + "It went to your story audience as it is now, since this story was posted before " + + "the app kept who it went to." + + /** After a take-down: done, anyone the message has not reached, and which audience was used. */ + fun takenDown(change: Session.StoryChange): String { + val total = change.reached + change.missed + val reach = + if (change.missed == 0) { + "Taken down." + } else { + "Taken down here. ${change.missed} of $total could not be reached right now, so " + + "their copy stays until it expires." + } + return if (change.asPosted) reach else "$reach $TODAYS_AUDIENCE" + } + + /** Whether [draft] may be sent as a story's new words: never empty, and not what it says already. */ + fun canSave( + draft: String, + current: String, + ): Boolean = draft.isNotBlank() && draft.trim() != current + + /** After an edit: nothing to say unless somebody still sees the old words. */ + fun edited(change: Session.StoryChange): String? { + val total = change.reached + change.missed + val reach = + if (change.missed == 0) { + null + } else { + "Changed here. ${change.missed} of $total could not be reached right now, so they " + + "still see the old words." + } + return if (change.asPosted) reach else listOfNotNull(reach, TODAYS_AUDIENCE).joinToString(" ") + } +} + +/** + * My own story: change its words, or take it down for everyone it went to + * (§6.15 for stories, §6.35), in one row. + */ +@Composable +private fun MyStoryControls( + story: Vault.Story, + model: MessengerViewModel, +) { + var editing by remember(story.id) { mutableStateOf(false) } + var takingDown by remember(story.id) { mutableStateOf(false) } + Row(horizontalArrangement = Arrangement.spacedBy(Space.xs), modifier = Modifier.fillMaxWidth()) { + KitSecondaryButton( + text = if (story.textOnly) "Edit words" else "Edit caption", + onClick = { editing = true }, + modifier = Modifier.weight(1f), + tag = "story-edit", + ) + KitSecondaryButton( + text = "Take down", + onClick = { takingDown = true }, + modifier = Modifier.weight(1f), + tag = "story-takedown", + danger = true, + ) + } + + if (takingDown) { + AlertDialog( + onDismissRequest = { takingDown = false }, + title = { Text(if (story.set != null) "Take down this story and all its pictures?" else "Take down this story?") }, + text = { Text(StoryWords.TAKE_DOWN_WARNING, fontSize = 14.sp, modifier = Modifier.testTag("story-takedown-warning")) }, + confirmButton = { + TextButton( + onClick = { + takingDown = false + model.takeDownStory(story) + }, + modifier = Modifier.testTag("story-takedown-confirm"), + ) { Text("Take down", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { takingDown = false }) { Text("Cancel") } }, + ) + } + + if (editing) { + var draft by remember(story.id) { mutableStateOf(story.caption) } + AlertDialog( + onDismissRequest = { editing = false }, + title = { Text(if (story.textOnly) "Edit your story" else "Edit the caption") }, + text = { + StoryCaptionEditor( + draft = draft, + onDraft = { draft = it }, + onTakeDownInstead = { + editing = false + takingDown = true + }, + ) + }, + confirmButton = { + TextButton( + onClick = { + editing = false + model.editStoryCaption(story, draft.trim()) + }, + enabled = StoryWords.canSave(draft, story.caption), + modifier = Modifier.testTag("story-edit-save"), + ) { Text("Save") } + }, + dismissButton = { TextButton(onClick = { editing = false }) { Text("Cancel") } }, + ) + } +} + +/** + * The words being edited, and what an edit cannot do: leave a story empty + * (§6.15). Taking it down is offered instead. + */ +@Composable +internal fun StoryCaptionEditor( + draft: String, + onDraft: (String) -> Unit, + onTakeDownInstead: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) { + OutlinedTextField( + value = draft, + onValueChange = onDraft, + modifier = Modifier.fillMaxWidth().testTag("story-edit-field"), + ) + Text( + "Everyone who has it sees that it was edited.", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (draft.isBlank()) { + Text( + "An edit cannot leave it empty. To remove it, take it down.", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("story-edit-empty"), + ) + TextButton(onClick = onTakeDownInstead, modifier = Modifier.testTag("story-edit-takedown")) { + Text("Take down instead") + } + } + } +} + /** * Caption + a deliberate Post for a picked story image, at the shell level so * whichever screen picked it (the strip or the Stories tab) gets the same @@ -591,25 +743,7 @@ fun StoryComposerDialog( title = { Text("New story") }, text = { Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) { - image?.let { - androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) { - androidx.compose.foundation.Image( - bitmap = it, - contentDescription = "Story preview", - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Radius.small)), - ) - if (draft.duration > 0) { - Text( - "▶ ${draft.duration.toInt()}s", - fontSize = 22.sp, - color = androidx.compose.ui.graphics.Color.White, - ) - } - } - } + image?.let { StoryComposerPreview(it, draft.width, draft.height, draft.duration) } androidx.compose.material3.OutlinedTextField( value = caption.value, onValueChange = { caption.value = it }, @@ -759,6 +893,29 @@ fun StoryComposerDialog( ) } +/** + * The picked picture as friends will see it in their feed (DESIGN item 27): + * the same shape and the same bars as the feed card, scaled to the composer. + */ +@Composable +internal fun StoryComposerPreview( + image: androidx.compose.ui.graphics.ImageBitmap, + width: Int, + height: Int, + durationSeconds: Double, +) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.testTag("story-preview")) { + StoryPhotoFrame(image, width, height) + if (durationSeconds > 0) { + Text( + "▶ ${durationSeconds.toInt()}s", + fontSize = 22.sp, + color = androidx.compose.ui.graphics.Color.White, + ) + } + } +} + /** * The story cards themselves, everyone's stories, or one honest message when * there are none. @@ -877,27 +1034,18 @@ private fun StoryImage( if (bitmap.value == null) failed.value = true } val loaded = bitmap.value - when { - loaded != null -> { - androidx.compose.foundation.Image( - bitmap = loaded.asImageBitmap(), - contentDescription = "Story image", - contentScale = androidx.compose.ui.layout.ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } - - failed.value -> { - Text( - "Image unavailable", - fontSize = 14.sp, - color = Palette.SecondaryText, - ) - } - - else -> { - Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText) - } + // The decoded picture's own shape when there is one: the envelope's size + // is the sender's claim, the pixels are not. + StoryPhotoFrame( + bitmap = loaded?.asImageBitmap(), + photoWidth = loaded?.width ?: ref.width, + photoHeight = loaded?.height ?: ref.height, + ) { + Text( + if (failed.value) "Image unavailable" else "Loading…", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoryFrame.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoryFrame.kt new file mode 100644 index 0000000..b4f0450 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoryFrame.kt @@ -0,0 +1,117 @@ +package io.flamenet.messenger.app + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.testTag + +/** + * How a story's photo sits in its feed card (DESIGN item 27): all of it, + * never cut. The card takes the photo's own shape between two wide and one + * high, and four wide and five high, and is never taller than 560dp; past + * either, the photo is fitted inside and plain bars fill the rest, beside a + * tall photo or above and below a wide one. The iPhone uses the same numbers. + */ +object StoryFrame { + /** The widest a card gets: twice as wide as it is tall. */ + const val MIN_TALL = 0.5f + + /** The tallest: four wide by five high. */ + const val MAX_TALL = 1.25f + + /** And never taller than this, for a tablet or a phone held sideways. */ + const val MAX_HEIGHT_DP = 560f + + /** A card, and the photo fitted inside it, in dp. */ + data class Frame( + val width: Float, + val height: Float, + val photoWidth: Float, + val photoHeight: Float, + ) { + /** Bars to the left and right: the photo is narrower than the card. */ + val barsBeside: Boolean get() = width - photoWidth > 0.5f + + /** Bars above and below: the photo is wider than the card's shape. */ + val barsAbove: Boolean get() = height - photoHeight > 0.5f + } + + /** + * The card for a photo of [photoWidth] by [photoHeight] pixels shown + * [width] dp wide. Only the photo's shape matters, never its pixel count, + * and a photo whose size is not known is treated as square. + */ + fun frame( + width: Float, + photoWidth: Int, + photoHeight: Int, + ): Frame { + val tall = if (photoWidth > 0 && photoHeight > 0) photoHeight.toFloat() / photoWidth else 1f + val height = minOf(width * tall.coerceIn(MIN_TALL, MAX_TALL), MAX_HEIGHT_DP) + // As large as fits in width by height at the photo's own shape. + val fitted = minOf(width, height / tall) + return Frame(width, height, fitted, fitted * tall) + } + + /** The width of a card in the stories feed, which pads the screen on both sides. */ + @Composable + fun feedCardWidthDp(): Float = LocalConfiguration.current.screenWidthDp - 2 * Space.md.value + + /** The card for [photoWidth] by [photoHeight] as the feed lays it out on this phone. */ + @Composable + fun inFeed( + photoWidth: Int, + photoHeight: Int, + ): Frame = frame(feedCardWidthDp(), photoWidth, photoHeight) +} + +/** + * A story's picture in the shape its feed card has on this phone, scaled to + * whatever width it is given, so the composer shows exactly what friends will + * see. [bitmap] is fitted whole; the bars take the theme's quiet surface. + */ +@Composable +fun StoryPhotoFrame( + bitmap: ImageBitmap?, + photoWidth: Int, + photoHeight: Int, + modifier: Modifier = Modifier, + /** Drawn in place of the picture while it loads, fails, or is a video. */ + instead: (@Composable BoxScope.() -> Unit)? = null, +) { + val frame = StoryFrame.inFeed(photoWidth, photoHeight) + Box( + modifier = + modifier + .fillMaxWidth() + .aspectRatio(frame.width / frame.height) + .clip(RoundedCornerShape(Radius.small)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .testTag("story-photo-frame"), + contentAlignment = Alignment.Center, + ) { + if (bitmap != null) { + Image( + bitmap = bitmap, + contentDescription = "Story image", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().testTag("story-photo"), + ) + } else { + instead?.invoke(this) + } + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/StoryFrameTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/StoryFrameTest.kt new file mode 100644 index 0000000..e279601 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/StoryFrameTest.kt @@ -0,0 +1,118 @@ +package io.flamenet.messenger.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The feed card's shape for a story's photo (DESIGN item 27): the whole photo, + * never cut, in a card that takes the photo's own shape between two wide by + * one high and four wide by five high, at most 560dp tall, with bars past + * either. The same numbers as the iPhone. + */ +class StoryFrameTest { + private val phone = 351f + + private fun close( + expected: Float, + actual: Float, + what: String, + ) = assertEquals(expected, actual, 0.01f, what) + + @Test + fun `a tall photo is shown whole, at four by five, with bars beside it`() { + val f = StoryFrame.frame(phone, photoWidth = 1080, photoHeight = 1920) + close(phone * 1.25f, f.height, "the card") + close(f.height, f.photoHeight, "the photo is cut at the top or bottom") + close(f.height * 1080f / 1920f, f.photoWidth, "the photo's shape changed") + assertTrue(f.barsBeside) + assertFalse(f.barsAbove) + } + + @Test + fun `a wide photo is shown whole, at two by one, with bars above and below`() { + val f = StoryFrame.frame(phone, photoWidth = 3000, photoHeight = 1000) + close(phone * 0.5f, f.height, "the card") + close(phone, f.photoWidth, "the photo is cut at the sides") + close(phone / 3f, f.photoHeight, "the photo's shape changed") + assertTrue(f.barsAbove) + assertFalse(f.barsBeside) + } + + @Test + fun `a photo whose shape fits takes the card exactly, with no bars`() { + for ((w, h) in listOf(1000 to 1000, 1600 to 1200, 1200 to 1500, 2000 to 1000)) { + val f = StoryFrame.frame(phone, w, h) + close(phone * h / w, f.height, "${w}x$h") + close(phone, f.photoWidth, "${w}x$h") + close(f.height, f.photoHeight, "${w}x$h") + assertFalse(f.barsBeside || f.barsAbove, "${w}x$h has bars") + } + } + + @Test + fun `only the shape counts, never the pixel count`() { + val tiny = StoryFrame.frame(phone, photoWidth = 3, photoHeight = 4) + val huge = StoryFrame.frame(phone, photoWidth = 12_000, photoHeight = 16_000) + assertEquals(tiny, huge, "a tiny photo and a huge one of the same shape are laid out alike") + close(phone * 1.25f, tiny.height, "a 3x4 photo is taller than four by five") + } + + @Test + fun `a sliver is still all there, however thin`() { + val f = StoryFrame.frame(phone, photoWidth = 1, photoHeight = 1000) + close(phone * 1.25f, f.height, "the card") + close(f.height / 1000f, f.photoWidth, "the photo") + val strip = StoryFrame.frame(phone, photoWidth = 1000, photoHeight = 1) + close(phone * 0.5f, strip.height, "the card") + close(phone, strip.photoWidth, "the photo") + } + + @Test + fun `a photo of unknown size is drawn as a square`() { + val f = StoryFrame.frame(phone, photoWidth = 0, photoHeight = 0) + close(phone, f.height, "the card") + close(phone, f.photoWidth, "the photo") + } + + @Test + fun `no card is taller than 560dp, on a tablet or sideways`() { + val wide = 900f + val f = StoryFrame.frame(wide, photoWidth = 1000, photoHeight = 1000) + close(560f, f.height, "the card") + close(560f, f.photoWidth, "a square photo stays square") + assertTrue(f.barsBeside) + val tall = StoryFrame.frame(wide, photoWidth = 1080, photoHeight = 1920) + close(560f, tall.height, "the card") + close(560f * 1080f / 1920f, tall.photoWidth, "the photo") + } + + @Test + fun `the words after a take-down say what was reached and which audience it went to`() { + assertEquals("Taken down.", StoryWords.takenDown(Session.StoryChange(reached = 3, missed = 0, asPosted = true))) + assertEquals( + "Taken down here. 1 of 4 could not be reached right now, so their copy stays until it expires.", + StoryWords.takenDown(Session.StoryChange(reached = 3, missed = 1, asPosted = true)), + ) + assertTrue( + StoryWords + .takenDown(Session.StoryChange(reached = 2, missed = 0, asPosted = false)) + .endsWith("It went to your story audience as it is now, since this story was posted before the app kept who it went to."), + ) + assertEquals(null, StoryWords.edited(Session.StoryChange(reached = 2, missed = 0, asPosted = true))) + assertEquals( + "Changed here. 2 of 2 could not be reached right now, so they still see the old words.", + StoryWords.edited(Session.StoryChange(reached = 0, missed = 2, asPosted = true)), + ) + } + + @Test + fun `the take-down warning is DESIGN item 25's, word for word`() { + assertEquals( + "This removes it from your friends' phones when their app gets the message. Anyone who already saw it " + + "may have kept a copy, and older versions of the app keep it until it expires.", + StoryWords.TAKE_DOWN_WARNING, + ) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/StoryScreensTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/StoryScreensTest.kt new file mode 100644 index 0000000..9ddc6bc --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/StoryScreensTest.kt @@ -0,0 +1,296 @@ +package io.flamenet.messenger.app + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.Attachment +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadows.ShadowDialog +import java.io.ByteArrayOutputStream +import java.io.File +import javax.crypto.KeyGenerator +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Stories on the screen (DESIGN items 25 to 27): a tall photo whole in its + * card, the composer showing it exactly as the feed will, the take-down + * asking first in DESIGN's words, an edited caption saying so, and the editor + * refusing to empty a story. Pictures go to build/screenshots/stories. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav") +class StoryScreensTest { + @get:Rule val compose = createAndroidComposeRule() + + private val out = File("build/screenshots/stories").also { it.mkdirs() } + private val app: Application get() = ApplicationProvider.getApplicationContext() + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + + override fun read(name: String) = records[name] + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + + override fun names() = records.keys.toList() + } + + /** A picture whose top and bottom are marked, so a crop would show. */ + private fun picture( + width: Int, + height: Int, + ): ByteArray { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + canvas.drawColor(Color.rgb(120, 170, 220)) + val band = Paint().apply { color = Color.rgb(200, 40, 60) } + canvas.drawRect(0f, 0f, width.toFloat(), height * 0.12f, band) + canvas.drawRect(0f, height * 0.88f, width.toFloat(), height.toFloat(), Paint().apply { color = Color.rgb(30, 120, 70) }) + val words = Paint().apply { color = Color.WHITE; textSize = height * 0.06f; isAntiAlias = true } + canvas.drawText("TOP", width * 0.05f, height * 0.09f, words) + canvas.drawText("BOTTOM", width * 0.05f, height * 0.97f, words) + return ByteArrayOutputStream().also { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }.toByteArray() + } + + private fun storyOf( + id: String, + author: Long, + width: Int, + height: Int, + caption: String, + editedAt: Long? = null, + ): Vault.Story { + // What `attachmentBytes` finds already decrypted on this phone. + File(app.cacheDir, "att-$id").writeBytes(picture(width, height)) + return Vault.Story( + id = id, + authorId = author, + mine = author == 1L, + ref = + Attachment.Ref( + kind = Attachment.STORY, + id = id, + key = ByteArray(32), + digest = ByteArray(32), + bytes = 1, + mime = "image/jpeg", + width = width, + height = height, + expires = System.currentTimeMillis() / 1000.0 + 20 * 3600, + ), + caption = caption, + postedAt = System.currentTimeMillis(), + seen = true, + editedAt = editedAt, + sentTo = if (author == 1L) listOf(2L) else null, + ) + } + + private fun model(): MessengerViewModel { + val identity = DeviceIdentity.create() + val vault = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + vault.saveIdentity(identity) + val base = "http://127.0.0.1:9/" + val session = Session("me", 1, identity, vault, RelayClient(base, "me", "pw"), MessengerApi(base, "me", "pw")) + return MessengerViewModel(app).also { it.useSessionForTesting(session) } + } + + private fun phase( + stories: List = emptyList(), + draft: MessengerViewModel.StoryDraft? = null, + ) = MessengerViewModel.Phase.SignedIn( + handle = "me", + userId = 1, + deviceId = "d-1", + relay = "relay.example", + status = "", + buddies = listOf(MessengerApi.Buddy(userId = 2, handle = "sam", displayName = "Sam", presence = "online")), + stories = stories, + storyDraft = draft, + ) + + private var screen by mutableStateOf<(@Composable ColumnScope.() -> Unit)?>(null) + private var shown = false + + private fun show( + dark: Boolean = false, + content: @Composable ColumnScope.() -> Unit, + ) { + DisplaySettings(compose.activity).appearance = if (dark) DisplaySettings.Appearance.DARK else DisplaySettings.Appearance.LIGHT + screen = content + if (!shown) { + shown = true + compose.setContent { + key(screen) { + MessengerTheme { Surface(modifier = Modifier.fillMaxSize()) { Column { screen?.invoke(this) } } } + } + } + } + compose.waitForIdle() + } + + private fun waitForPicture() = + compose.waitUntil(5_000) { + compose.onAllNodes(hasTestTag("story-photo"), useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() + } + + private fun draw( + view: View, + name: String, + ) { + if (view.width == 0 || view.height == 0) { + val spec = View.MeasureSpec.makeMeasureSpec(1031, View.MeasureSpec.AT_MOST) + view.measure(spec, View.MeasureSpec.makeMeasureSpec(2233, View.MeasureSpec.AT_MOST)) + view.layout(0, 0, view.measuredWidth, view.measuredHeight) + } + val bitmap = Bitmap.createBitmap(view.width.coerceAtLeast(1), view.height.coerceAtLeast(1), Bitmap.Config.ARGB_8888) + view.draw(Canvas(bitmap)) + val file = File(out, "$name.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("wrote ${file.absolutePath}") + } + + private fun shoot(name: String) { + compose.waitForIdle() + compose.activityRule.scenario.onActivity { draw(it.window.decorView, name) } + } + + private fun shootDialog(name: String) { + compose.waitForIdle() + val dialog = ShadowDialog.getLatestDialog() + draw(dialog.window!!.decorView, name) + } + + private fun aspect(node: SemanticsNodeInteraction): Float { + val bounds = node.getUnclippedBoundsInRoot() + return (bounds.right - bounds.left).value / (bounds.bottom - bounds.top).value + } + + private val tallId = "a".repeat(64) + + @Test + fun `a tall photo is shown whole in the feed, light and dark`() { + val model = model() + val story = storyOf(tallId, author = 2, width = 540, height = 960, caption = "Sunset from the ridge, all of it") + for (dark in listOf(false, true)) { + show(dark) { StoriesCards(model, phase(listOf(story))) } + waitForPicture() + assertEquals(1 / 1.25f, aspect(compose.onNodeWithTag("story-photo-frame", useUnmergedTree = true)), 0.01f) + shoot(if (dark) "stories-tall-dark" else "stories-tall-light") + } + } + + @Test + fun `the composer shows the photo exactly as the feed will`() { + val model = model() + for ((w, h) in listOf(540 to 960, 1600 to 600, 1000 to 1000)) { + val id = "b".repeat(60) + "%04d".format(w) + val story = storyOf(id, author = 2, width = w, height = h, caption = "") + show { StoriesCards(model, phase(listOf(story))) } + waitForPicture() + val feed = aspect(compose.onNodeWithTag("story-photo-frame", useUnmergedTree = true)) + + // What the composer draws above its caption field, at a dialog's width. + val picked = ImagePrep.decodeBounded(File(app.cacheDir, "att-$id").readBytes())!!.asImageBitmap() + show { Box(Modifier.width(280.dp)) { StoryComposerPreview(picked, w, h, durationSeconds = 0.0) } } + compose.onNodeWithTag("story-preview", useUnmergedTree = true).assertExists() + val preview = aspect(compose.onNodeWithTag("story-photo-frame", useUnmergedTree = true)) + assertEquals(feed, preview, 0.01f, "the composer showed ${w}x$h in another shape than the feed") + } + } + + @Test + fun `taking my story down asks first, in DESIGN's words`() { + val model = model() + val mine = storyOf("c".repeat(64), author = 1, width = 540, height = 960, caption = "My story") + show { StoriesCards(model, phase(listOf(mine))) } + compose.onNodeWithTag("story-takedown").performClick() + val warning = compose.onNodeWithTag("story-takedown-warning", useUnmergedTree = true) + val said = warning.fetchSemanticsNode().config[SemanticsProperties.Text].joinToString { it.text } + assertEquals(StoryWords.TAKE_DOWN_WARNING, said) + compose.onNodeWithText("Take down this story?").assertExists() + shootDialog("stories-takedown-confirm") + compose.onNodeWithText("Cancel").performClick() + compose.onNodeWithTag("story-takedown-warning", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `an edited caption says so`() { + val model = model() + val story = + storyOf(tallId, author = 2, width = 540, height = 960, caption = "Sunset from the ridge, the whole picture", editedAt = 1L) + show { StoriesCards(model, phase(listOf(story))) } + waitForPicture() + val line = compose.onNodeWithTag("story-set-count-$tallId", useUnmergedTree = true) + val said = line.fetchSemanticsNode().config[SemanticsProperties.Text].joinToString { it.text } + assertTrue(said.endsWith(" · Edited"), "an edited story does not say so: $said") + shoot("stories-edited-caption") + } + + @Test + fun `the editor never empties a story, and offers taking it down instead`() { + // The editor itself, outside its dialog: a text field inside a dialog + // window never lets Robolectric settle, with or without these changes. + var draft by mutableStateOf("Words") + var offered = false + show { StoryCaptionEditor(draft = draft, onDraft = { draft = it }, onTakeDownInstead = { offered = true }) } + compose.onNodeWithTag("story-edit-empty", useUnmergedTree = true).assertDoesNotExist() + compose.onNodeWithTag("story-edit-field").performTextClearance() + assertEquals("", draft) + compose.onNodeWithTag("story-edit-empty", useUnmergedTree = true).assertExists() + compose.onNodeWithTag("story-edit-takedown").performClick() + assertTrue(offered, "Take down was not offered for an emptied story") + assertFalse(StoryWords.canSave(" ", "Words"), "an empty caption could be saved") + assertFalse(StoryWords.canSave("Words ", "Words"), "an unchanged caption could be sent as an edit") + assertTrue(StoryWords.canSave("New words", "Words")) + } +}