Clear unsent chat text when panic wipe is confirmed (#1027)

The chat screen stored the unsent message separately from the state cleared
by panic wipe, leaving old text visible after completion. Mention
suggestions could also put text back into the cleared input.

Cleared the input and its command and mention suggestions when the user
confirms the wipe. Rejected mention selections when suggestions are hidden
or the nickname is no longer in the current list, including older click
handlers and rows still visible during their closing animation.
This commit is contained in:
heyaim 2026-11-06 17:54:31 -06:00 committed by GitHub
parent 998f168487
commit 2fc6f47472
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 862 additions and 7 deletions

View File

@ -8,6 +8,8 @@ plugins {
// Opt-in instrumentation of the minified variant; production release signing is unchanged.
val releaseUiTests = providers.gradleProperty("releaseUiTests").orNull == "true"
val composerUiTests = providers.gradleProperty("composerUiTests").orNull == "true"
require(!(releaseUiTests && composerUiTests)) { "Select one UI test configuration" }
val githubReleaseCertSha256 = providers
.environmentVariable("BITCHAT_GITHUB_RELEASE_CERT_SHA256")
@ -42,7 +44,9 @@ android {
"\"$normalizedGithubReleaseCertSha256\""
)
testInstrumentationRunner = if (releaseUiTests) {
testInstrumentationRunner = if (composerUiTests) {
"com.bitchat.android.ui.ComposerPanicTestRunner"
} else if (releaseUiTests) {
"com.bitchat.android.ui.ReleaseScannerTestRunner"
} else {
"com.bitchat.android.ui.PerformanceTestRunner"
@ -59,8 +63,13 @@ android {
includeInBundle = false
}
if (composerUiTests) {
sourceSets.getByName("androidTest").kotlin.directories.add("src/composerAndroidTest/java")
}
buildTypes {
debug {
if (composerUiTests) applicationIdSuffix = ".composerTest"
ndk {
// Include x86_64 for emulator support during development
abiFilters += listOf("arm64-v8a", "x86_64", "armeabi-v7a", "x86")

View File

@ -0,0 +1,149 @@
package com.bitchat.android.ui
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.lifecycle.ViewModelProvider
import android.view.KeyEvent
import androidx.test.platform.app.InstrumentationRegistry
import com.bitchat.android.MainActivity
import com.bitchat.android.R
import com.bitchat.android.services.AppStateStore
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
class ComposerEdgeCasesInstrumentedTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
private val editor = hasSetTextAction() and SemanticsMatcher.expectValue(SemanticsProperties.ImeAction, ImeAction.Send)
private fun field() = compose.onNode(editor)
private fun start() {
compose.waitUntil(30_000) { compose.onAllNodes(editor).fetchSemanticsNodes().size == 1 }
field().assertTextEquals("")
}
private fun openConfirmation() {
compose.mainClock.autoAdvance = false
try { repeat(3) { compose.onNodeWithContentDescription(context.getString(R.string.cd_open_about)).performClick() } }
finally { compose.mainClock.autoAdvance = true }
compose.onNodeWithText(context.getString(R.string.panic_confirm_title)).assertIsDisplayed()
}
private fun confirmAndWait() {
val prior = AppStateStore.publicMessages.value.map { it.id }.toSet()
compose.onNodeWithText(context.getString(R.string.panic_confirm_action)).performClick()
compose.waitUntil(30_000) {
AppStateStore.publicMessages.value.any {
it.id !in prior && it.content == context.getString(R.string.system_panic_completed)
}
}
}
@Test fun backPreservesMultilineUnicodeAndSelectedTextThenWipeClears() {
start()
val draft = "SYNTHETIC\n雪🙂 é\nline two"
field().performTextInput(draft)
field().performTextInputSelection(TextRange(2, 7))
openConfirmation()
InstrumentationRegistry.getInstrumentation().sendKeyDownUpSync(KeyEvent.KEYCODE_BACK)
field().assertTextEquals(draft)
field().assert(SemanticsMatcher.expectValue(SemanticsProperties.TextSelectionRange, TextRange(2, 7)))
openConfirmation(); confirmAndWait()
field().assertTextEquals("")
field().assert(SemanticsMatcher.expectValue(SemanticsProperties.TextSelectionRange, TextRange.Zero))
field().performTextInput("SYNTHETIC_FRESH")
field().assertTextEquals("SYNTHETIC_FRESH")
}
@Test fun emptyAndRepeatedWipesPreserveFreshTyping() {
start(); openConfirmation(); confirmAndWait(); field().assertTextEquals("")
field().performTextInput("SYNTHETIC_SECOND")
openConfirmation(); confirmAndWait(); field().assertTextEquals("")
field().performTextInput("SYNTHETIC_AFTER_SECOND")
field().assertTextEquals("SYNTHETIC_AFTER_SECOND")
}
@Test fun confirmedWipeDismissesCommandSuggestions() {
start(); field().performTextInput("/w")
val vm = ViewModelProvider(compose.activity)[ChatViewModel::class.java]
compose.waitUntil(5_000) { vm.showCommandSuggestions.value && vm.commandSuggestions.value.isNotEmpty() }
val command = vm.commandSuggestions.value.first().command
compose.onNode(hasText(command) and !hasSetTextAction(), useUnmergedTree = true).assertIsDisplayed()
openConfirmation(); confirmAndWait()
field().assertTextEquals("")
assertFalse("pre-wipe command suggestion popup must be dismissed", vm.showCommandSuggestions.value)
compose.onNode(hasText(command) and !hasSetTextAction(), useUnmergedTree = true).assertDoesNotExist()
field().performTextInput("/w")
compose.waitUntil(5_000) { vm.showCommandSuggestions.value }
compose.onNode(hasText(command) and !hasSetTextAction(), useUnmergedTree = true).assertIsDisplayed()
}
private fun seedMention(nickname: String = "SyntheticMentionPeer"): ChatViewModel {
val vm = ViewModelProvider(compose.activity)[ChatViewModel::class.java]
compose.runOnIdle {
vm.meshService.updatePeerInfo(
peerID = "aaaabbbbccccdddd", nickname = nickname,
noisePublicKey = ByteArray(32) { 1 }, signingPublicKey = ByteArray(32) { 2 },
isVerified = false
)
assertEquals(nickname, vm.meshServiceFacade.getPeerNicknames()["aaaabbbbccccdddd"])
}
field().performTextInput("@" + nickname.dropLast(4))
compose.waitUntil(5_000) { vm.showMentionSuggestions.value }
compose.onNodeWithText(nickname, substring = true).assertIsDisplayed()
return vm
}
@Test fun confirmedWipeDismissesMentionSuggestions() {
start(); val vm = seedMention()
openConfirmation(); confirmAndWait(); field().assertTextEquals("")
assertFalse("synthetic peer must be removed from fresh mesh", vm.meshServiceFacade.getPeerNicknames().containsKey("aaaabbbbccccdddd"))
compose.onNodeWithText("SyntheticMentionPeer", substring = true).assertDoesNotExist()
assertFalse("pre-wipe mention suggestion popup must be dismissed", vm.showMentionSuggestions.value)
seedMention("FreshSyntheticPeer")
compose.onNodeWithText("FreshSyntheticPeer", substring = true).performClick()
field().assertTextEquals("@FreshSyntheticPeer ")
}
@Test fun exitingMentionRowsCannotRefillInput() {
start()
// Exercise a captured callback before any redraw, separately from pointer timing.
val vm = seedMention()
val staleAction = compose.onNodeWithText("SyntheticMentionPeer", substring = true)
.fetchSemanticsNode().config[SemanticsActions.OnClick].action!!
openConfirmation()
val confirmAction = compose.onNodeWithText(context.getString(R.string.panic_confirm_action))
.fetchSemanticsNode().config[SemanticsActions.OnClick].action!!
compose.mainClock.autoAdvance = false
try {
compose.runOnUiThread {
assertTrue(confirmAction())
assertFalse(vm.showMentionSuggestions.value)
assertTrue(staleAction())
}
compose.mainClock.advanceTimeByFrame()
field().assertTextEquals("")
} finally {
compose.mainClock.autoAdvance = true
}
compose.waitUntil(30_000) {
!ChatViewModel::class.java.getDeclaredField("panicClearInProgress")
.apply { isAccessible = true }.getBoolean(vm)
}
for (frames in listOf(1, 2)) {
field().performTextReplacement("")
seedMention()
openConfirmation()
compose.mainClock.autoAdvance = false
try {
confirmAndWait()
repeat(frames) { compose.mainClock.advanceTimeByFrame() }
val retained = compose.onAllNodesWithText("SyntheticMentionPeer", substring = true)
val count = retained.fetchSemanticsNodes().size
println("EXIT_FRAME=$frames retainedRows=$count")
assertEquals("fixture must click a retained mention exit row at frame $frames", 1, count)
retained[0].performClick()
compose.mainClock.advanceTimeBy(500)
field().assertTextEquals("")
} finally {
compose.mainClock.autoAdvance = true
}
}
}
}

View File

@ -0,0 +1,178 @@
package com.bitchat.android.ui
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.text.input.ImeAction
import androidx.lifecycle.ViewModelProvider
import androidx.test.platform.app.InstrumentationRegistry
import com.bitchat.android.MainActivity
import com.bitchat.android.R
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.ConversationRepository
import com.bitchat.android.services.ConversationStoreState
import kotlinx.coroutines.CoroutineDispatcher
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.CoroutineContext
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
class ComposerOutcomeInstrumentedTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
private val editor = hasSetTextAction() and SemanticsMatcher.expectValue(SemanticsProperties.ImeAction, ImeAction.Send)
private fun field() = compose.onNode(editor)
private fun start(): ChatViewModel {
compose.waitUntil(30_000) { compose.onAllNodes(editor).fetchSemanticsNodes().size == 1 }
return ViewModelProvider(compose.activity)[ChatViewModel::class.java]
}
private fun confirmation() {
compose.mainClock.autoAdvance = false
try { repeat(3) { compose.onNodeWithContentDescription(context.getString(R.string.cd_open_about)).performClick() } }
finally { compose.mainClock.autoAdvance = true }
compose.onNodeWithText(context.getString(R.string.panic_confirm_title)).assertIsDisplayed()
}
private fun confirm() = compose.onNodeWithText(context.getString(R.string.panic_confirm_action)).performClick()
private fun ids() = AppStateStore.publicMessages.value.map { it.id }.toSet()
private fun awaitOutcome(prior: Set<String>, resource: Int) {
compose.waitUntil(30_000) { AppStateStore.publicMessages.value.any { it.id !in prior && it.content == context.getString(resource) } }
}
private fun inProgress(vm: ChatViewModel): Boolean = ChatViewModel::class.java.getDeclaredField("panicClearInProgress").apply { isAccessible = true }.getBoolean(vm)
private fun withCleanup(vararg cleanup: () -> Unit, block: () -> Unit) {
var failure: Throwable? = null
try {
block()
} catch (error: Throwable) {
failure = error
} finally {
for (action in cleanup) {
try {
action()
} catch (error: Throwable) {
val primary = failure
if (primary == null) failure = error
else if (primary !== error) primary.addSuppressed(error)
}
}
}
failure?.let { throw it }
}
@Test fun failedConversationWipeStillClearsConfirmedDraft() {
val vm = start()
val original = ConversationRepository.getInstance(context)
val name = "synthetic-composer-failed-store.db"
val path = context.getDatabasePath(name)
path.deleteRecursively(); assertTrue(path.mkdirs())
val repository = ConversationRepository(context, databaseName = name)
withCleanup(
{ compose.waitUntil(30_000) { !inProgress(vm) } },
{ AppStateStore.setConversationRepositoryForTest(original) },
{ repository.closeForTest() },
{ path.deleteRecursively() },
{
val prior = ids()
compose.runOnIdle {
check(!inProgress(vm)) { "previous panic wipe has not finished" }
vm.panicClearAllData()
}
awaitOutcome(prior, R.string.system_panic_completed)
}
) {
AppStateStore.setConversationRepositoryForTest(repository)
field().performTextInput("SYNTHETIC_FAILED_WIPE")
confirmation(); val prior = ids(); confirm()
awaitOutcome(prior, R.string.system_panic_incomplete)
compose.waitUntil(5_000) { !inProgress(vm) }
field().assertTextEquals("")
assertTrue(repository.storeState.value is ConversationStoreState.Error)
assertFalse(AppStateStore.publicMessages.value.any { it.id !in prior && it.content == context.getString(R.string.system_panic_completed) })
}
}
@Test fun secondConfirmationClearsDraftWhileFirstWipeWaitsThenDrains() {
val vm = start()
val original = ConversationRepository.getInstance(context)
val executor = Executors.newSingleThreadExecutor()
val entered = CountDownLatch(1); val release = CountDownLatch(1)
val queued = AtomicInteger()
val dispatcher = object : CoroutineDispatcher() {
override fun dispatch(context: CoroutineContext, block: Runnable) { queued.incrementAndGet(); executor.execute(block) }
}
val repository = ConversationRepository(context, dispatcher, "synthetic-composer-held-store.db")
withCleanup(
{ release.countDown() },
{ compose.waitUntil(30_000) { !inProgress(vm) } },
{ AppStateStore.setConversationRepositoryForTest(original) },
{ repository.closeForTest() },
{ executor.shutdown() },
{ assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)) },
{ context.deleteDatabase("synthetic-composer-held-store.db") }
) {
executor.execute { entered.countDown(); check(release.await(30, TimeUnit.SECONDS)) }
assertTrue(entered.await(5, TimeUnit.SECONDS))
AppStateStore.setConversationRepositoryForTest(repository)
field().performTextInput("SYNTHETIC_FIRST")
confirmation(); val prior = ids(); confirm()
compose.waitUntil(5_000) { queued.get() == 1 && inProgress(vm) }
field().assertTextEquals("")
assertEquals(1L, release.count)
field().performTextInput("SYNTHETIC_WHILE_WAITING")
confirmation(); confirm(); field().assertTextEquals("")
assertEquals("second confirmation must not enqueue another wipe", 1, queued.get())
assertTrue(inProgress(vm))
release.countDown()
awaitOutcome(prior, R.string.system_panic_completed)
compose.waitUntil(5_000) { !inProgress(vm) }
field().assertTextEquals("")
field().performTextInput("SYNTHETIC_AFTER_DRAIN")
field().assertTextEquals("SYNTHETIC_AFTER_DRAIN")
}
}
@Test fun sendButtonAcceptsOrdinaryAndFreshPostWipeTextIntoLocalSink() {
val vm = start()
val captured = CopyOnWriteArrayList<String>()
var delegateToRestore = vm.meshServiceFacade
var installedSink: MeshService? = null
fun installSink() {
val delegate = vm.meshServiceFacade
delegateToRestore = delegate
val sink = object : MeshService by delegate {
override fun sendMessage(content: String, mentions: List<String>, channel: String?) { captured.add(content) }
}
installedSink = sink
compose.runOnIdle { ChatViewModel::class.java.getDeclaredField("unifiedMeshService").apply { isAccessible = true }.set(vm, sink) }
}
fun send(value: String) {
field().performTextReplacement(value)
compose.onNodeWithContentDescription(context.getString(R.string.send_message)).performClick()
compose.waitUntil(5_000) { captured.contains(value) }
field().assertTextEquals("")
assertTrue(AppStateStore.publicMessages.value.any { it.content == value })
}
withCleanup({
// A successful wipe replaced the service; unwrap the currently installed sink.
compose.runOnIdle {
if (vm.meshServiceFacade === installedSink) {
ChatViewModel::class.java.getDeclaredField("unifiedMeshService").apply { isAccessible = true }.set(vm, delegateToRestore)
}
}
}) {
installSink(); send("SYNTHETIC_ORDINARY_SEND")
field().performTextInput("SYNTHETIC_DISCARDED")
confirmation(); val prior = ids(); confirm(); awaitOutcome(prior, R.string.system_panic_completed)
field().assertTextEquals("")
assertFalse(AppStateStore.publicMessages.value.any { it.content == "SYNTHETIC_ORDINARY_SEND" })
installSink(); send("SYNTHETIC_FRESH_SEND")
assertEquals(listOf("SYNTHETIC_ORDINARY_SEND", "SYNTHETIC_FRESH_SEND"), captured.toList())
}
}
}

View File

@ -0,0 +1,79 @@
package com.bitchat.android.ui
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.text.input.ImeAction
import androidx.lifecycle.ViewModelProvider
import com.bitchat.android.services.AppStateStore
import androidx.test.platform.app.InstrumentationRegistry
import com.bitchat.android.BitchatApplication
import com.bitchat.android.MainActivity
import com.bitchat.android.R
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class ComposerPanicInstrumentedTest {
@get:Rule
val compose = createAndroidComposeRule<MainActivity>()
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
private val editor = hasSetTextAction() and
SemanticsMatcher.expectValue(SemanticsProperties.ImeAction, ImeAction.Send)
@Test
fun confirmedPanicClearsDraftWhileCancelAndFreshTypingPreserveText() {
compose.waitUntil(30_000) {
compose.onAllNodes(editor).fetchSemanticsNodes().size == 1
}
assertTrue(context.applicationContext is BitchatApplication)
compose.onNode(editor).assertTextEquals("")
// Retain a real earlier completion so the confirmation cannot pass on a stale banner.
val previousIds = AppStateStore.publicMessages.value.map { it.id }.toSet()
val viewModel = ViewModelProvider(compose.activity)[ChatViewModel::class.java]
compose.runOnIdle { viewModel.panicClearAllData() }
awaitNewCompletion(previousIds)
compose.onNode(editor).performTextInput("SYNTHETIC_UNSENT_DRAFT")
openPanicConfirmation()
compose.onNodeWithText(context.getString(android.R.string.cancel)).performClick()
compose.onNode(editor).assertTextEquals("SYNTHETIC_UNSENT_DRAFT")
openPanicConfirmation()
val beforeConfirmation = AppStateStore.publicMessages.value.map { it.id }.toSet()
compose.onNodeWithText(context.getString(R.string.panic_confirm_action)).performClick()
awaitNewCompletion(beforeConfirmation)
compose.onNode(editor).assertTextEquals("")
compose.onNode(editor).performTextInput("SYNTHETIC_FRESH_DRAFT")
compose.onNode(editor).assertTextEquals("SYNTHETIC_FRESH_DRAFT")
}
private fun awaitNewCompletion(previousIds: Set<String>) {
compose.waitUntil(30_000) {
AppStateStore.publicMessages.value.any {
it.id !in previousIds && it.content == context.getString(R.string.system_panic_completed)
}
}
}
private fun openPanicConfirmation() {
compose.mainClock.autoAdvance = false
try {
repeat(3) {
compose.onNodeWithContentDescription(context.getString(R.string.cd_open_about))
.performClick()
}
} finally {
compose.mainClock.autoAdvance = true
}
compose.onNodeWithText(context.getString(R.string.panic_confirm_title)).assertIsDisplayed()
}
}

View File

@ -0,0 +1,25 @@
package com.bitchat.android.ui
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
/** Synthetic offline configuration; preserves the real Application and ViewModel startup. */
class ComposerPanicTestRunner : AndroidJUnitRunner() {
override fun callApplicationOnCreate(app: Application) {
check(app.packageName == "com.bitchat.droid.composerTest") {
"Panic tests require the dedicated composer test installation"
}
app.getSharedPreferences("bitchat_permissions", Context.MODE_PRIVATE).edit()
.putBoolean("first_time_onboarding_complete", true)
.putBoolean("optional_permission_requested_android.permission.POST_NOTIFICATIONS", true).commit()
app.getSharedPreferences("bitchat_debug_settings", Context.MODE_PRIVATE).edit()
.putBoolean("ble_enabled", false).putBoolean("wifi_aware_enabled", false).commit()
app.getSharedPreferences("bitchat_settings", Context.MODE_PRIVATE).edit()
.putBoolean("battery_optimization_skipped", true)
.putBoolean("background_location_skipped", true).putString("tor_mode", "OFF").commit()
app.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE).edit()
.putBoolean("location_services_enabled", false).commit()
super.callApplicationOnCreate(app)
}
}

View File

@ -437,11 +437,15 @@ fun ChatScreen(viewModel: ChatViewModel) {
)
},
onMentionSuggestionClick = { mention: String ->
val mentionText = viewModel.selectMentionSuggestion(mention, messageText.text)
messageText = TextFieldValue(
text = mentionText,
selection = TextRange(mentionText.length)
)
// A retained callback may run before visibility has recomposed.
if (viewModel.showMentionSuggestions.value &&
mention in viewModel.mentionSuggestions.value) {
val mentionText = viewModel.selectMentionSuggestion(mention, messageText.text)
messageText = TextFieldValue(
text = mentionText,
selection = TextRange(mentionText.length)
)
}
},
selectedPrivatePeer = null,
currentChannel = currentChannel,
@ -571,6 +575,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
TextButton(
onClick = {
showPanicConfirmation = false
messageText = TextFieldValue("")
viewModel.clearSuggestions()
viewModel.panicClearAllData()
},
colors = ButtonDefaults.textButtonColors(
@ -739,7 +745,9 @@ fun ChatInputSection(
MentionSuggestionsBox(
suggestions = displayedMentionSuggestions,
mentionPeerIdentities = mentionPeerIdentities,
onSuggestionClick = onMentionSuggestionClick,
onSuggestionClick = { nickname ->
if (mentionPickerVisible) onMentionSuggestionClick(nickname)
},
modifier = Modifier.fillMaxWidth()
)
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)

View File

@ -0,0 +1,96 @@
# Public composer panic regression
These nine app-screen tests exercise the real application, Activity and panic-wipe confirmation. They check that confirming a wipe clears the old draft and suggestions, and that stale mention selections cannot restore text.
## 1. Prepare a disposable emulator environment
These tests trigger destructive cleanup. Use a fresh disposable emulator with no user data and block outbound network access. Do not use a personal device. The supplied runner creates its own emulator and refuses physical devices.
Before starting, have:
- An Android SDK with a rootable emulator system image already installed.
- `ANDROID_HOME` set to the local Android SDK directory.
- JDK 21 available for `avdmanager`.
- Matching app and test APKs, built as described below.
- An unused results directory and available emulator/ADB ports.
The example command uses the API 35 Google APIs ARM image. The runner does not build APKs or download dependencies.
## 2. Build the app and test APKs
Run from the repository root:
```sh
./gradlew -PcomposerUiTests=true :app:assembleDebug :app:assembleDebugAndroidTest
```
This property includes the composer test sources and selects the dedicated test installation, `com.bitchat.droid.composerTest`. Normal debug builds keep their usual application ID and runner. Do not combine `composerUiTests` with `releaseUiTests`.
## 3. Run all nine app-screen tests
After the build, run this command from the repository root. The results directory must not already exist.
```sh
python3 tools/composer/run_regression.py \
--sdk "$ANDROID_HOME" \
--image 'system-images;android-35;google_apis;arm64-v8a' \
--apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk \
--test-apk app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--output composer-regression-results
```
For another emulator ABI, select a matching installed system image and app APK. Use the test APK built with that app.
The default emulator port is `5558`, and the private ADB server port is `5088`. If either is occupied, supply unused values with `--port` and `--adb-port`. The emulator port must be even, and the following port must also be available. The runner checks port availability before starting.
### What the runner does
- Creates a temporary Android Virtual Device (AVD) and deletes it during cleanup.
- Installs IPv4 and IPv6 rules that reject external network traffic before installing the app. It checks that each rejection rule is first in its OUTPUT chain before installation and after the tests. These endpoint checks do not continuously monitor rule changes or traffic.
- Turns off device Wi-Fi, mobile data and Bluetooth.
- Enables the disposable emulator's device-wide location setting for onboarding and grants the test app Bluetooth and location permissions. The test app's own location feature remains disabled.
- Keeps the screen awake, dismisses the keyguard and stops the dedicated test app before starting instrumentation.
- Runs all nine tests and requires a successful nine-test result.
- Retains emulator and instrumentation logs locally. Failure summaries include a test identifier, recognized framework exception type and test-source location when available; they omit raw assertion values and device logs.
The test configuration also disables the app's BLE and Wi-Fi Aware features. Those app settings alone do not isolate network traffic. Use the supplied runner's setup instead of running instrumentation alone on an unprepared device. The dedicated package keeps normal app installations separate.
## What the app-screen tests cover
- An empty initial input; Cancel preserving a synthetic draft; confirmed wipe completing and clearing that draft; and typing afterward.
- Back dismissing confirmation without changing selected multiline Unicode text.
- Empty and repeated wipes.
- Clearing command and mention suggestions, and using fresh mentions afterward.
- Clicking mention rows during their closing animation.
- Invoking a captured mention callback immediately after confirmation, before redraw. This checks a controlled callback invocation, not the timing of an ordinary screen tap.
- Clearing the confirmed draft when conversation storage cleanup fails.
- Confirming a second wipe while the first waits for storage, then checking completion after releasing the queue.
- Using the actual Send button before and after wipe, with messages captured by a local test transport.
The mention fixture inserts an unverified synthetic nickname into the local peer map; no real peer is involved.
## Check that the tests detect missing protections
Remove one protection at a time, run the affected tests, then restore it and rerun:
1. Remove the input reset from the real panic-confirmation callback. The main regression test should fail its empty-input assertion after wipe completion.
2. Remove the suggestion-clear call. The command and mention popup checks should fail.
3. In `ChatScreen`, remove the current-state check of `viewModel.showMentionSuggestions.value` and membership in `viewModel.mentionSuggestions.value` from `onMentionSuggestionClick`, leaving its mention-selection body in place. In `exitingMentionRowsCannotRefillInput`, the empty-input assertion after invoking the captured callback before redraw should fail. Keep the separate `mentionPickerVisible` guard unchanged for this check.
Restore every protection before running the full suite.
## 4. Run the test-script checks
From the repository root, run the eleven Python checks:
```sh
python3 tools/composer/test_run_regression.py
```
These check result parsing, failure reporting, cleanup error handling and firewall-rule order. They do not run the nine app-screen tests and do not require an emulator.
## Testing limits
- This suite is opt-in. Default JVM tests and CI commands do not run it.
- The send test checks local acceptance at a capturing transport. It does not establish radio transmission or delivery to another device.
- The tests do not establish storage erasure or cancellation of outgoing transfers.

View File

@ -88,3 +88,9 @@ bridges, and vendored Noise code are excluded from first-party coverage metrics.
- A temporary quarantine must include an issue and removal condition.
- Unexpected skips fail review. Existing skips must be restored or replaced by
equivalent coverage.
## Composer UI regression
The [composer panic regression](composer-panic-test.md) runs locally in a
disposable emulator with a separate installation and blocked external egress.
This destructive UI suite is opt-in; default JVM and CI commands do not execute it.

View File

@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Run composer regressions on an owned disposable, network-blocked emulator."""
import argparse
import os
from pathlib import Path
import re
import socket
import subprocess
import sys
import tempfile
import time
PACKAGE = "com.bitchat.droid.composerTest"
CLASSES = ",".join("com.bitchat.android.ui." + name for name in (
"ComposerPanicInstrumentedTest", "ComposerEdgeCasesInstrumentedTest", "ComposerOutcomeInstrumentedTest"))
TEST_CLASSES = {name.rsplit(".", 1)[-1] for name in CLASSES.split(",")}
def failure_summary(output):
"""Expose test identifiers and exception types, never raw assertion values or paths."""
details = []
current_class = current_test = ""
for line in output.splitlines():
if line.startswith("INSTRUMENTATION_STATUS: class="):
candidate = line.split("=", 1)[1].rsplit(".", 1)[-1]
current_class = candidate if candidate in TEST_CLASSES else ""
elif line.startswith("INSTRUMENTATION_STATUS: test="):
candidate = line.split("=", 1)[1]
current_test = candidate if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,160}", candidate) else ""
elif line.startswith("INSTRUMENTATION_STATUS_CODE: -") and current_class and current_test:
details.append(current_class + "." + current_test + " (failed or skipped)")
exception = re.match(r"(?:INSTRUMENTATION_STATUS: stack=)?((?:java|android|androidx|kotlin|org\.junit)\.[A-Za-z0-9_.$]*(?:Exception|Error|ComparisonFailure))(?::|$)", line.strip())
if exception: details.append(exception.group(1))
location = re.search(r"\((Composer[A-Za-z]+Test\.kt:\d+)\)", line)
if location and location.group(1).split(":", 1)[0][:-3] in TEST_CLASSES:
details.append(location.group(1))
if not details and current_class and current_test:
details.append("last test: " + current_class + "." + current_test)
return "; ".join(dict.fromkeys(details)) or "no completed test details; inspect the local instrumentation log"
def require_success(output):
if not re.search(r"^OK \(9 tests\)$", output, re.MULTILINE) or any(
marker in output for marker in ("FAILURES!!!", "INSTRUMENTATION_FAILED", "Process crashed", "INSTRUMENTATION_ABORTED", "INSTRUMENTATION_STATUS_CODE: -3", "INSTRUMENTATION_STATUS_CODE: -4")
):
raise RuntimeError("Composer instrumentation failed: " + failure_summary(output))
def run_instrumentation(command, log_path):
try:
with log_path.open("w") as test_log:
subprocess.run(command, stdout=test_log, stderr=subprocess.STDOUT, check=True, timeout=600)
except subprocess.TimeoutExpired:
raise RuntimeError("Composer instrumentation timed out after 600s: " + failure_summary(log_path.read_text())) from None
except subprocess.CalledProcessError as error:
raise RuntimeError(f"Composer instrumentation exited {error.returncode}: " + failure_summary(log_path.read_text())) from None
output = log_path.read_text()
require_success(output)
return output
def require_first_output_reject(rules):
"""Check rule order at this instant; this is not continuous traffic monitoring."""
output_rules = [line.strip() for line in rules.splitlines() if line.startswith("-A OUTPUT ")]
expected = "-A OUTPUT ! -o lo -j REJECT"
accepted = {expected, expected + " --reject-with icmp-port-unreachable",
expected + " --reject-with icmp6-port-unreachable"}
if not output_rules or output_rules[0] not in accepted:
raise RuntimeError("External egress rejection is not the first OUTPUT rule")
def finish_cleanup(actions, primary_failure):
"""Attempt each teardown step without replacing an active test/setup failure."""
failures = []
for action in actions:
try:
action()
except Exception as error:
failures.append(type(error).__name__)
if failures:
message = "Composer cleanup failed: " + ", ".join(failures)
if primary_failure is not None:
print(message, file=sys.stderr)
else:
raise RuntimeError(message)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sdk", type=Path, required=True)
parser.add_argument("--image", required=True, help="Installed system image package, without Play Store")
parser.add_argument("--apk", type=Path, required=True)
parser.add_argument("--test-apk", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--port", type=int, default=5558)
parser.add_argument("--adb-port", type=int, default=5088)
args = parser.parse_args()
if args.port % 2 or not 5554 <= args.port <= 65534:
parser.error("Use an even emulator port between 5554 and 65534")
for apk in (args.apk, args.test_apk):
if not apk.is_file(): parser.error("Build both matching APKs before starting the emulator")
for port in (args.port, args.port + 1, args.adb_port):
with socket.socket() as probe:
probe.bind(("127.0.0.1", port))
args.output.mkdir(parents=True, exist_ok=False)
adb = [str(args.sdk / "platform-tools/adb"), "-P", str(args.adb_port), "-s", f"emulator-{args.port}"]
def call(*items, timeout=60):
return subprocess.check_output(adb + list(items), stderr=subprocess.STDOUT, text=True, timeout=timeout).strip()
with tempfile.TemporaryDirectory(prefix="composer-regression-") as scratch:
env = dict(os.environ, ANDROID_AVD_HOME=scratch, ANDROID_ADB_SERVER_PORT=str(args.adb_port))
manager = args.sdk / "cmdline-tools/latest/bin/avdmanager"
subprocess.run([str(manager), "create", "avd", "-n", "composer_regression", "-k", args.image,
"-p", str(Path(scratch) / "device.avd")], input="no\n", text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True, env=env, timeout=90)
with (args.output / "emulator.log").open("w") as log:
process = subprocess.Popen([str(args.sdk / "emulator/emulator"), "-avd", "composer_regression",
"-port", str(args.port), "-no-window", "-no-audio", "-no-snapshot", "-no-boot-anim",
"-gpu", "swiftshader_indirect", "-memory", "2048", "-no-metrics", "-crash-report-mode", "disabled"], env=env, stdout=log, stderr=subprocess.STDOUT)
try:
deadline = time.monotonic() + 180
while True:
if process.poll() is not None: raise RuntimeError("Owned emulator exited during startup")
try:
if call("shell", "getprop", "sys.boot_completed", timeout=5) == "1": break
except (subprocess.SubprocessError, OSError): pass
if time.monotonic() >= deadline: raise TimeoutError("Owned emulator did not boot")
time.sleep(1)
if call("shell", "getprop", "ro.kernel.qemu") != "1": raise RuntimeError("Refusing a physical device")
call("root"); call("wait-for-device")
if call("shell", "id", "-u") != "0": raise RuntimeError("A rootable disposable image is required")
def verify_firewalls(stage):
for firewall in ("iptables", "ip6tables"):
rules = call("shell", firewall, "-S", "OUTPUT")
(args.output / f"{firewall}-{stage}.log").write_text(rules)
require_first_output_reject(rules)
# Block external egress before installing or starting either package.
for firewall in ("iptables", "ip6tables"):
call("shell", firewall, "-I", "OUTPUT", "1", "!", "-o", "lo", "-j", "REJECT")
verify_firewalls("before")
call("shell", "svc", "wifi", "disable")
call("shell", "svc", "data", "disable")
call("shell", "cmd", "bluetooth_manager", "disable")
call("shell", "cmd", "location", "set-location-enabled", "true")
for apk in (args.apk, args.test_apk): call("install", "-r", str(apk.resolve()), timeout=90)
for permission in ("BLUETOOTH_SCAN", "BLUETOOTH_CONNECT", "BLUETOOTH_ADVERTISE", "ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION"):
call("shell", "pm", "grant", PACKAGE, "android.permission." + permission)
call("shell", "settings", "put", "system", "screen_off_timeout", "2147483647")
call("shell", "input", "keyevent", "KEYCODE_WAKEUP")
call("shell", "wm", "dismiss-keyguard")
call("shell", "am", "force-stop", PACKAGE)
output = run_instrumentation(adb + ["shell", "am", "instrument", "-w", "-r", "-e", "class", CLASSES,
PACKAGE + ".test/com.bitchat.android.ui.ComposerPanicTestRunner"], args.output / "instrumentation.log")
verify_firewalls("after")
print("Composer regression: 9 tests passed; outbound reject rule first before and after tests")
finally:
primary_failure = sys.exc_info()[1]
def stop_emulator():
try: call("emu", "kill", timeout=10)
except (subprocess.SubprocessError, OSError): pass
try: process.wait(timeout=15)
except subprocess.TimeoutExpired:
process.terminate()
try: process.wait(timeout=10)
except subprocess.TimeoutExpired: process.kill(); process.wait(timeout=10)
def stop_adb():
subprocess.run([str(args.sdk / "platform-tools/adb"), "-P", str(args.adb_port), "kill-server"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, check=True)
finish_cleanup((stop_emulator, stop_adb), primary_failure)
if __name__ == "__main__":
try:
main()
except RuntimeError as error:
print(str(error), file=sys.stderr)
raise SystemExit(1)
except (OSError, subprocess.SubprocessError) as error:
# Keep local paths, subprocess arguments and raw device output out of CI output.
print("Composer setup or cleanup failed: " + type(error).__name__, file=sys.stderr)
raise SystemExit(1)

View File

@ -0,0 +1,121 @@
import contextlib
import io
import importlib.util
from pathlib import Path
import unittest
from unittest import mock
import subprocess
import sys
import tempfile
spec = importlib.util.spec_from_file_location("runner", Path(__file__).with_name("run_regression.py"))
runner = importlib.util.module_from_spec(spec)
spec.loader.exec_module(runner)
class InstrumentationOutcomeTest(unittest.TestCase):
def test_first_output_reject_passes(self):
for suffix in ("", " --reject-with icmp-port-unreachable", " --reject-with icmp6-port-unreachable"):
with self.subTest(suffix=suffix):
runner.require_first_output_reject(
"-P OUTPUT ACCEPT\n-A OUTPUT ! -o lo -j REJECT" + suffix + "\n-A OUTPUT -j ACCEPT\n")
def test_missing_or_displaced_output_reject_fails(self):
for rules in ("-P OUTPUT ACCEPT\n", "-A OUTPUT -j ACCEPT\n-A OUTPUT ! -o lo -j REJECT\n",
"-A OUTPUT ! -o lo -j ACCEPT\n"):
with self.subTest(rules=rules), self.assertRaises(RuntimeError):
runner.require_first_output_reject(rules)
def test_cleanup_timeout_preserves_primary_and_attempts_remaining_actions(self):
primary = RuntimeError("synthetic instrumentation failure")
actions = []
def fail():
actions.append("first")
raise subprocess.TimeoutExpired("SYNTHETIC_PRIVATE_COMMAND", 10)
captured = io.StringIO()
with contextlib.redirect_stderr(captured), self.assertRaises(RuntimeError) as caught:
try:
raise primary
finally:
runner.finish_cleanup((fail, lambda: actions.append("second")), sys.exc_info()[1])
self.assertIs(caught.exception, primary)
self.assertEqual(actions, ["first", "second"])
self.assertIn("TimeoutExpired", captured.getvalue())
self.assertNotIn("SYNTHETIC_PRIVATE_COMMAND", captured.getvalue())
def test_cleanup_failure_after_success_is_not_silenced(self):
def fail():
raise subprocess.TimeoutExpired("SYNTHETIC_PRIVATE_COMMAND", 10)
with self.assertRaisesRegex(RuntimeError, "Composer cleanup failed: TimeoutExpired"):
runner.finish_cleanup((fail,), None)
runner.finish_cleanup((lambda: None,), None)
def test_complete_run_passes(self):
runner.require_success("Time: 1\nOK (9 tests)\n")
def test_short_empty_crashed_or_failed_runs_reject(self):
for output in ("", "OK (1 test)", "OK (8 tests)", "OK (9 tests)\nFAILURES!!!",
"OK (9 tests)\nINSTRUMENTATION_FAILED", "OK (9 tests)\nProcess crashed",
"OK (9 tests)\nINSTRUMENTATION_ABORTED",
"OK (9 tests)\nINSTRUMENTATION_STATUS_CODE: -3",
"OK (9 tests)\nINSTRUMENTATION_STATUS_CODE: -4"):
with self.subTest(output=output), self.assertRaises(RuntimeError): runner.require_success(output)
def test_failure_summary_names_test_without_raw_assertion_or_path(self):
output = self.failure_output()
with self.assertRaises(RuntimeError) as caught:
runner.require_success(output)
message = str(caught.exception)
self.assertIn("ComposerPanicInstrumentedTest.confirmedPanicClearsDraftWhileCancelAndFreshTypingPreserveText", message)
self.assertIn("java.lang.AssertionError", message)
self.assertIn("ComposerPanicInstrumentedTest.kt:54", message)
self.assertNotIn("SYNTHETIC_PRIVATE_VALUE", message)
self.assertNotIn("/synthetic/private", message)
@staticmethod
def failure_output():
return ("INSTRUMENTATION_STATUS: class=com.bitchat.android.ui.ComposerPanicInstrumentedTest\n"
"INSTRUMENTATION_STATUS: test=confirmedPanicClearsDraftWhileCancelAndFreshTypingPreserveText\n"
"INSTRUMENTATION_STATUS: stack=java.lang.AssertionError: SYNTHETIC_PRIVATE_VALUE /synthetic/private\n"
" at synthetic.test(ComposerPanicInstrumentedTest.kt:54)\n"
"INSTRUMENTATION_STATUS_CODE: -2\nFAILURES!!!\n")
def test_timeout_and_nonzero_exit_keep_concise_details_and_raw_local_log(self):
for error in (subprocess.TimeoutExpired("synthetic-command", 600),
subprocess.CalledProcessError(1, "synthetic-command")):
with self.subTest(error=type(error).__name__), tempfile.TemporaryDirectory() as scratch:
log = Path(scratch) / "instrumentation.log"
def fail(*args, **kwargs):
kwargs["stdout"].write(self.failure_output())
kwargs["stdout"].flush()
raise error
with mock.patch.object(runner.subprocess, "run", side_effect=fail), self.assertRaises(RuntimeError) as caught:
runner.run_instrumentation(["synthetic-command"], log)
self.assertIn("ComposerPanicInstrumentedTest", str(caught.exception))
self.assertNotIn("SYNTHETIC_PRIVATE_VALUE", str(caught.exception))
self.assertIn("SYNTHETIC_PRIVATE_VALUE", log.read_text())
def test_interrupted_run_identifies_last_started_test(self):
output = ("INSTRUMENTATION_STATUS: class=com.bitchat.android.ui.ComposerPanicInstrumentedTest\n"
"INSTRUMENTATION_STATUS: test=confirmedPanicClearsDraftWhileCancelAndFreshTypingPreserveText\n")
self.assertIn("last test: ComposerPanicInstrumentedTest.", runner.failure_summary(output))
def test_real_subprocess_failure_reports_test_without_raw_values(self):
with tempfile.TemporaryDirectory() as scratch:
command = [sys.executable, "-c", "import sys; print(" + repr(self.failure_output()) + "); sys.exit(1)"]
with self.assertRaises(RuntimeError) as caught:
runner.run_instrumentation(command, Path(scratch) / "instrumentation.log")
self.assertIn("exited 1", str(caught.exception))
self.assertIn("ComposerPanicInstrumentedTest", str(caught.exception))
self.assertNotIn("SYNTHETIC_PRIVATE_VALUE", str(caught.exception))
def test_junit_comparison_failure_type_without_expected_or_actual_values(self):
output = self.failure_output().replace("java.lang.AssertionError: SYNTHETIC_PRIVATE_VALUE /synthetic/private",
"org.junit.ComparisonFailure: expected:<SYNTHETIC_EXPECTED> but was:<SYNTHETIC_ACTUAL>")
summary = runner.failure_summary(output)
self.assertIn("org.junit.ComparisonFailure", summary)
self.assertNotIn("SYNTHETIC_EXPECTED", summary)
self.assertNotIn("SYNTHETIC_ACTUAL", summary)
if __name__ == "__main__": unittest.main()