Inline media-send v3.

This commit is contained in:
Alex Hart
2026-08-19 19:05:49 -04:00
committed by Cody Henthorne
parent 2d8bdec1e4
commit 40c06ea232
68 changed files with 130 additions and 5358 deletions
@@ -13,7 +13,12 @@ import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.compose.ui.node.RootForTest
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getAllSemanticsNodes
import androidx.compose.ui.semantics.getOrNull
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
@@ -33,7 +38,7 @@ import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3Activity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
@@ -42,6 +47,7 @@ import java.io.ByteArrayOutputStream
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.signal.mediasend.R as MediaSendR
/**
* End-to-end launch tests for [MainActivity], covering cold-launch and onNewIntent paths
@@ -121,9 +127,9 @@ class MainNavigationLaunchTest {
/**
* Image-share cold-launch: the dispatch path through `ShareOrDraftData.StartSendMedia`
* that hops the user from the conversation into the media-send screen
* ([MediaSelectionActivity]). Asserts that the secondary activity actually launches and
* that its [MediaReviewFragment] surfaces the recipient's display name in the top
* corner — i.e. it knows who the share is targeted at.
* ([MediaSendV3Activity]). Asserts that the secondary activity actually launches and that
* its edit screen's summary pill names both the recipient and the shared photo — i.e. it
* knows who the share is targeted at and what is being sent.
*/
@Test
fun coldLaunch_shareImageIntent_opensMediaSendForRecipient() {
@@ -131,13 +137,18 @@ class MainNavigationLaunchTest {
val intent = shareImageIntent(recipient = recipient, media = media)
launchSync(intent).use { launched ->
val mediaSend = launched.awaitActivity(MediaSelectionActivity::class.java, timeoutMs = 20_000)
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
launched.awaitActivity(MediaSendV3Activity::class.java, timeoutMs = 20_000)
await(timeoutMs = 15_000, description = "recipient label populated in MediaReviewFragment") {
// await() already runs the predicate on the main thread; nesting another
// runOnMainSync here would throw "can not be called from the main application thread".
mediaSend.findViewById<TextView>(R.id.recipient)?.text?.toString() == expectedName
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
val expectedMedia = context.resources.getQuantityString(MediaSendR.plurals.MediaEditScreen__photo, 1, 1)
await(timeoutMs = 15_000, description = "media editor summary pill showing \"$expectedName\" / \"$expectedMedia\"") {
// Re-resolve each poll rather than closing over the awaitActivity instance: the flow
// toggles requestedOrientation as it settles, and a recreated activity would leave us
// reading a dead composition.
val mediaSend = launched.latestActivity(MediaSendV3Activity::class.java) ?: return@await false
val texts = mediaSend.composeTexts()
expectedName in texts && expectedMedia in texts
}
// Exactly one ConversationFragment should have been created — the share dispatch
@@ -580,10 +591,9 @@ class MainNavigationLaunchTest {
}
/**
* Build a [Media] backed by a real 1×1 JPEG. The media-send screen attempts to decode
* the image during MediaReviewFragment setup, so a fake byte array won't survive — we
* need genuine JPEG bytes for the fragment to reach the state where `R.id.recipient`
* is populated.
* Build a [Media] backed by a real 1×1 JPEG. The media editor decodes each page to render
* it, so genuine JPEG bytes keep the screen in the state a real share would put it in
* rather than one recovering from a decode failure.
*/
private fun realJpegMedia(): Media {
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
@@ -739,6 +749,32 @@ class MainNavigationLaunchTest {
MainNavigationListLocation.STORIES -> StoriesLandingFragment::class.java
}
/**
* Every string the activity's Compose hierarchy is currently rendering, read straight off the
* semantics tree. Media send is Compose end-to-end, so there is no view to findViewById; and the
* looper never goes idle here (see [launchSync]), which rules out the idle-synchronized matchers
* of a Compose test rule. Main-thread read — [await] already provides that.
*/
private fun Activity.composeTexts(): List<String> {
return window.decorView.composeRoots()
.flatMap { it.semanticsOwner.getAllSemanticsNodes(mergingEnabled = false) }
.flatMap { it.config.getOrNull(SemanticsProperties.Text).orEmpty() }
.map { it.text }
}
private fun View.composeRoots(): List<RootForTest> {
val roots = mutableListOf<RootForTest>()
if (this is RootForTest) {
roots += this
}
if (this is ViewGroup) {
for (i in 0 until childCount) {
roots += getChildAt(i).composeRoots()
}
}
return roots
}
private fun awaitListFragment(launched: LaunchedActivity, location: MainNavigationListLocation) {
val expected = listFragmentClass(location)
try {
@@ -808,17 +844,20 @@ class MainNavigationLaunchTest {
*/
val activity: MainActivity get() = checkNotNull(activityHolder[0]) { "No active MainActivity" }
/** Most-recently-created, not-yet-destroyed activity of [clazz], or null. */
fun <T : Activity> latestActivity(clazz: Class<T>): T? = synchronized(allActivities) {
allActivities.lastOrNull { clazz.isInstance(it) }?.let { clazz.cast(it) }
}
/**
* Poll until an activity of [clazz] has been created, then return it. Used to assert
* the share-image flow's hop into MediaSelectionActivity.
* the share-image flow's hop into [MediaSendV3Activity].
*/
fun <T : Activity> awaitActivity(clazz: Class<T>, timeoutMs: Long = 10_000): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val match = synchronized(allActivities) {
allActivities.firstOrNull { clazz.isInstance(it) }
}
if (match != null) return clazz.cast(match)!!
val match = latestActivity(clazz)
if (match != null) return match
Thread.sleep(50)
}
val seen = synchronized(allActivities) { allActivities.map { it::class.simpleName } }
@@ -830,7 +869,7 @@ class MainNavigationLaunchTest {
}
override fun close() {
// Don't wait for looper idle — secondary activities (e.g. MediaSelectionActivity
// Don't wait for looper idle — secondary activities (e.g. MediaSendV3Activity
// opened by share processing) can keep it busy indefinitely. Finish every tracked
// activity so subsequent tests start from a clean slate.
val toFinish = synchronized(allActivities) { allActivities.toList() }