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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-10-04 11:28:18 -04:00
co-authored by Claude Opus 5.5
parent c0d88e85ec
commit 6dff1f0064
5 changed files with 800 additions and 71 deletions
@@ -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
@@ -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,
)
}
}
@@ -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)
}
}
}
@@ -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,
)
}
}
@@ -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<ComponentActivity>()
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<String, ByteArray>()
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<Vault.Story> = 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"))
}
}