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")) + } +}