Skip to content

Commit 6df0ae5

Browse files
committed
Merge origin/main into Postbox 0.2.2 release
# Conflicts: # apps/android/app/src/androidTest/java/dev/pi/postbox/question/QuestionWorkflowScreenTest.kt # apps/android/app/src/main/java/dev/pi/postbox/MainActivity.kt # apps/android/app/src/main/java/dev/pi/postbox/protocol/PostboxProtocol.kt # apps/android/app/src/main/java/dev/pi/postbox/question/QuestionPriority.kt # apps/android/app/src/main/java/dev/pi/postbox/question/QuestionWorkflowScreen.kt # apps/android/app/src/main/java/dev/pi/postbox/question/QuestionWorkflowViewModel.kt # apps/android/app/src/test/java/dev/pi/postbox/protocol/PostboxProtocolClientTest.kt # apps/android/app/src/test/java/dev/pi/postbox/protocol/PostboxProtocolDtoTest.kt # apps/android/app/src/test/java/dev/pi/postbox/protocol/PostboxProtocolFixtures.kt # apps/android/app/src/test/java/dev/pi/postbox/question/QuestionWorkflowViewModelTest.kt # apps/android/app/src/test/java/dev/pi/postbox/question/SidebarGroupsTest.kt
2 parents 56a9f22 + 00bcb45 commit 6df0ae5

36 files changed

Lines changed: 8062 additions & 99 deletions

IMPLEMENTATION_REPORT.md

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# Android Question Chat polish pass v3 report
2+
3+
## Scope completed
4+
5+
Completed on the current branch after inspecting the interrupted diff first and keeping only the useful production/test work:
6+
- While generating, the composer action now swaps in-place from Send/Steer to a Stop icon button in the same circular slot, with accessible label `Stop` and no separate Stop button.
7+
- The chat transcript is now a bounded weighted `LazyColumn` history above a bottom composer, with one production IME inset ownership path on the composer.
8+
- Starter answers are now compact visual pills with preserved button semantics and 48dp minimum interactive height.
9+
- The Question action row now uses web-matching copy and hierarchy: `Submit` + icon and `Add note` + icon on one responsive row, with `Cancel` below.
10+
- Added focused Compose/instrumentation coverage for:
11+
- send/stop action swapping in one slot
12+
- compact starter semantics vs visual size
13+
- one-row Submit/Add note copy and layout
14+
- bounded real-IME probe/geometry seam with bounded skip instead of hanging the emulator
15+
- Preserved the useful screenshot/evidence harness, removed the unused `TestComposeImeActivity` / androidTest manifest experiment, and kept the minimal debug host activity actually used by the committed instrumentation.
16+
17+
## Library rationale
18+
19+
Researched maintained chat UI kits before editing.
20+
21+
Not adopted:
22+
- GetStream `stream-chat-android-ai-compose` / `ChatComposer`
23+
24+
Reason:
25+
- it is maintained and does cover keyboard/composer/chat concerns,
26+
- but it pulls in Stream-specific licensing/ecosystem assumptions plus attachment/voice/message abstractions and a visual/state model this app would mostly override,
27+
- while backend-neutral maintained Compose chat kits remain sparse for this narrow app-owned Question Chat.
28+
29+
Decision:
30+
- keep the app-owned UI,
31+
- use the standard bounded `LazyColumn` + anchored composer pattern from current Compose inset guidance,
32+
- preserve the existing deep owner / safe renderer architecture.
33+
34+
Compose insets reference consulted before editing:
35+
- fetched Compose inset-consumption guidance under `/tmp/rpiv-fetch-S93HR6/content.txt` (nested `imePadding` / inset consumption behavior)
36+
- accepted workspace contract in `docs/prototypes/2026-07-29-android-question-chat-workspace.md`
37+
- current interrupted task brief in `/tmp/android-chat-polish3.md`
38+
- rereview findings in `/tmp/android-chat-t1-rereview-report.md`
39+
40+
## Diff inspection performed first
41+
42+
Inspected the full interrupted working tree before changing anything:
43+
- `git status --short`
44+
- `git diff --stat`
45+
- full diffs for all modified tracked files
46+
- contents of the new androidTest/debug harness files
47+
- current production/test files and the web action row source
48+
49+
## Changed files
50+
51+
Production:
52+
- `apps/android/app/src/main/java/dev/pi/postbox/question/QuestionChatUi.kt`
53+
- `apps/android/app/src/main/java/dev/pi/postbox/question/QuestionWorkflowScreen.kt`
54+
- `apps/android/app/src/main/java/dev/pi/postbox/ui/theme/PostalDecorations.kt`
55+
56+
Compose/instrumentation tests:
57+
- `apps/android/app/src/androidTest/java/dev/pi/postbox/question/QuestionChatUiTest.kt`
58+
- `apps/android/app/src/androidTest/java/dev/pi/postbox/question/QuestionWorkflowScreenTest.kt`
59+
- `apps/android/app/src/androidTest/java/dev/pi/postbox/question/QuestionChatImeGeometryTest.kt`
60+
- `apps/android/app/src/androidTest/java/dev/pi/postbox/question/QuestionChatEvidenceCaptureTest.kt`
61+
- `apps/android/app/src/debug/AndroidManifest.xml`
62+
- `apps/android/app/src/debug/java/dev/pi/postbox/question/QuestionTestHostActivity.kt`
63+
64+
Docs:
65+
- `IMPLEMENTATION_REPORT.md`
66+
67+
## Evidence
68+
69+
Evidence directory:
70+
- `/tmp/android-chat-ui-evidence-v3/`
71+
72+
Captured after screenshots:
73+
- `after-idle-composer.png`
74+
- `after-generating-stop-composer.png`
75+
- `after-starter-pills.png`
76+
- `after-question-action-row.png`
77+
78+
Retained before screenshots:
79+
- `before-idle-composer.png`
80+
- `before-generating-stop-composer.png`
81+
- `before-starter-pills.png`
82+
- `before-question-action-row.png`
83+
- `before-keyboard-visible.png`
84+
85+
Important IME note:
86+
- The committed `QuestionChatImeGeometryTest` performs a bounded real-IME probe on `emulator-5554` and skips instead of hanging when this API-26 headless emulator never surfaces a software keyboard.
87+
- I did **not** fabricate an `after-keyboard-visible.png` screenshot on this runner.
88+
- See `/tmp/android-chat-ui-evidence-v3/NOTES.md`.
89+
90+
## Commands run
91+
92+
Environment / repo inspection:
93+
- `env | sort | grep '^PI_'`
94+
- `git status --short`
95+
- `git diff --stat`
96+
- full `git diff ...` over the interrupted file set
97+
- `read`, `grep`, `find`, `ls`
98+
99+
Focused verification:
100+
- `cd apps/android && ./gradlew testDebugUnitTest --tests 'dev.pi.postbox.question.QuestionWorkflowQuestionChatTest' --tests 'dev.pi.postbox.questionchat.QuestionChatWorkspaceShellTest'`
101+
- `cd apps/android && ./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class='dev.pi.postbox.question.QuestionChatUiTest,dev.pi.postbox.question.QuestionWorkflowScreenTest,dev.pi.postbox.question.QuestionChatImeGeometryTest'`
102+
103+
Evidence capture:
104+
- `cd apps/android && ./gradlew installDebug installDebugAndroidTest`
105+
- `adb -s emulator-5554 shell am instrument -w -e class dev.pi.postbox.question.QuestionChatEvidenceCaptureTest -e prefix after dev.pi.postbox.test/androidx.test.runner.AndroidJUnitRunner`
106+
- `adb -s emulator-5554 exec-out run-as dev.pi.postbox cat /data/user/0/dev.pi.postbox/files/android-chat-ui-evidence-v3/<file> > /tmp/android-chat-ui-evidence-v3/<file>`
107+
108+
Emulator recovery / bounded retry work:
109+
- restarted the API-26 emulator with bounded waits after System UI ANR loops during IME experiments:
110+
- `/home/dev/Android/Sdk/emulator/emulator @postbox_api26_tcg -no-window -no-snapshot-load -no-snapshot-save -gpu swiftshader_indirect -accel off`
111+
112+
Final Android gates:
113+
- `cd apps/android && ./gradlew testDebugUnitTest connectedDebugAndroidTest lintDebug assembleDebug assembleDebugAndroidTest`
114+
115+
Diff hygiene:
116+
- `git diff --check`
117+
118+
## Validation status
119+
120+
Passed:
121+
- focused JVM tests for `QuestionWorkflowQuestionChatTest` and `QuestionChatWorkspaceShellTest`
122+
- focused connected tests for `QuestionChatUiTest` and `QuestionWorkflowScreenTest`
123+
- full Android gates:
124+
- `testDebugUnitTest`
125+
- `connectedDebugAndroidTest`
126+
- `lintDebug`
127+
- `assembleDebug`
128+
- `assembleDebugAndroidTest`
129+
130+
Skipped by design after bounded probing:
131+
- `QuestionChatImeGeometryTest`
132+
- `QuestionChatEvidenceCaptureTest` cases in the default full suite (they require explicit evidence prefix)
133+
134+
## Residual notes
135+
136+
- The API-26 headless emulator on this runner never surfaced a real IME under bounded automation, even after switching soft keyboards and restarting the emulator without snapshots. The committed IME probe now skips rather than hanging the suite or fabricating proof.
137+
- `LocalClipboardManager` deprecation warnings remain outside this polish slice.
138+
- No push performed.
139+
- No PR opened.

apps/android/app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ dependencies {
5050
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.0")
5151
implementation("com.google.firebase:firebase-messaging")
5252
implementation("com.squareup.okhttp3:okhttp:4.12.0")
53+
implementation("org.commonmark:commonmark:0.29.0")
5354
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
5455
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
5556

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
package dev.pi.postbox.question
2+
3+
import android.content.Context
4+
import androidx.test.platform.app.InstrumentationRegistry
5+
import dev.pi.postbox.R
6+
import java.io.File
7+
import java.io.IOException
8+
import java.security.KeyStore
9+
import java.util.UUID
10+
import kotlinx.coroutines.runBlocking
11+
import org.junit.After
12+
import org.junit.Assert.assertEquals
13+
import org.junit.Assert.assertFalse
14+
import org.junit.Assert.assertNull
15+
import org.junit.Assert.assertTrue
16+
import org.junit.Before
17+
import org.junit.Test
18+
import org.xmlpull.v1.XmlPullParser
19+
20+
class AndroidKeystoreQuestionDraftStoreTest {
21+
private lateinit var context: Context
22+
private lateinit var rootDirectory: File
23+
private lateinit var keyAlias: String
24+
25+
@Before
26+
fun setUp() {
27+
context = InstrumentationRegistry.getInstrumentation().targetContext
28+
rootDirectory = File(context.noBackupFilesDir, "question_drafts_test_${UUID.randomUUID()}")
29+
keyAlias = "dev.pi.postbox.question_drafts_test_${UUID.randomUUID()}"
30+
}
31+
32+
@After
33+
fun tearDown() {
34+
rootDirectory.deleteRecursively()
35+
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
36+
if (keyStore.containsAlias(keyAlias)) keyStore.deleteEntry(keyAlias)
37+
}
38+
39+
@Test
40+
fun productionRootAndBothRuleFormatsExcludeDraftsFromBackupAndTransfer() {
41+
val productionRoot = AndroidKeystoreQuestionDraftStore.defaultRootDirectory(context)
42+
assertTrue(
43+
productionRoot.canonicalPath.startsWith(context.noBackupFilesDir.canonicalPath + File.separator)
44+
)
45+
assertEquals("question_drafts_v1", productionRoot.name)
46+
assertEquals(1, countDraftExclusions(R.xml.backup_rules))
47+
assertEquals(2, countDraftExclusions(R.xml.data_extraction_rules))
48+
}
49+
50+
@Test
51+
fun encryptedDraftRoundTripsAcrossStoreRecreationWithoutPlaintextIdentityOrContent() = runBlocking {
52+
val key = QuestionDraftKey("https://postbox.private.example/", "ask-secret-request")
53+
val draft = QuestionAnswerDraft(
54+
selectedValues = listOf("private-choice", "other"),
55+
note = "private note contents"
56+
)
57+
val firstStore = store()
58+
59+
assertEquals(QuestionDraftStoreResult.Success(Unit), firstStore.save(key, draft))
60+
assertEquals(QuestionDraftStoreResult.Success(draft), store().load(key))
61+
62+
val files = rootDirectory.walkTopDown().filter { it.isFile }.toList()
63+
assertEquals(1, files.size)
64+
val path = files.single().relativeTo(rootDirectory).path
65+
val ciphertextText = files.single().readBytes().toString(Charsets.ISO_8859_1)
66+
listOf(
67+
"postbox.private.example",
68+
"ask-secret-request",
69+
"private-choice",
70+
"private note contents"
71+
).forEach { secret ->
72+
assertFalse(path.contains(secret))
73+
assertFalse(ciphertextText.contains(secret))
74+
}
75+
}
76+
77+
@Test
78+
fun tamperedCiphertextReturnsFiniteCorruptFailure() = runBlocking {
79+
val key = QuestionDraftKey("https://postbox.example/", "ask-tamper")
80+
val draft = QuestionAnswerDraft(listOf("one"), "tamper check")
81+
val store = store()
82+
assertEquals(QuestionDraftStoreResult.Success(Unit), store.save(key, draft))
83+
val encryptedFile = rootDirectory.walkTopDown().single { it.isFile }
84+
val tampered = encryptedFile.readBytes().also { bytes ->
85+
bytes[bytes.lastIndex] = (bytes.last().toInt() xor 0x01).toByte()
86+
}
87+
encryptedFile.writeBytes(tampered)
88+
89+
assertEquals(
90+
QuestionDraftStoreResult.Failure(QuestionDraftStoreFailure.CORRUPT),
91+
store.load(key)
92+
)
93+
assertEquals(QuestionDraftStoreResult.Success(null), store.load(key))
94+
}
95+
96+
@Test
97+
fun oversizedCiphertextReturnsFiniteCorruptFailureAndIsDiscarded() = runBlocking {
98+
val key = QuestionDraftKey("https://postbox.example/", "ask-oversized")
99+
val store = store()
100+
assertEquals(
101+
QuestionDraftStoreResult.Success(Unit),
102+
store.save(key, QuestionAnswerDraft(listOf("one"), "bounded"))
103+
)
104+
rootDirectory.walkTopDown().single { it.isFile }
105+
.writeBytes(ByteArray(600_000))
106+
107+
assertEquals(
108+
QuestionDraftStoreResult.Failure(QuestionDraftStoreFailure.CORRUPT),
109+
store.load(key)
110+
)
111+
assertEquals(QuestionDraftStoreResult.Success(null), store.load(key))
112+
}
113+
114+
@Test
115+
fun ciphertextSwappedBetweenDraftKeysFailsAuthenticationAndIsDiscarded() = runBlocking {
116+
val firstKey = QuestionDraftKey("https://first.example/", "ask-shared")
117+
val secondKey = QuestionDraftKey("https://second.example/", "ask-shared")
118+
val firstDraft = QuestionAnswerDraft(listOf("one"), "first")
119+
val secondDraft = QuestionAnswerDraft(listOf("other"), "second")
120+
val store = store()
121+
assertEquals(QuestionDraftStoreResult.Success(Unit), store.save(firstKey, firstDraft))
122+
val firstFile = rootDirectory.walkTopDown().single { it.isFile }
123+
assertEquals(QuestionDraftStoreResult.Success(Unit), store.save(secondKey, secondDraft))
124+
val secondFile = rootDirectory.walkTopDown()
125+
.filter { it.isFile && it != firstFile }
126+
.single()
127+
secondFile.writeBytes(firstFile.readBytes())
128+
129+
assertEquals(
130+
QuestionDraftStoreResult.Failure(QuestionDraftStoreFailure.CORRUPT),
131+
store.load(secondKey)
132+
)
133+
assertEquals(QuestionDraftStoreResult.Success(null), store.load(secondKey))
134+
assertEquals(QuestionDraftStoreResult.Success(firstDraft), store.load(firstKey))
135+
}
136+
137+
@Test
138+
fun outOfBoundsDraftReturnsFiniteWriteFailureWithoutCreatingAFile() = runBlocking {
139+
val key = QuestionDraftKey("https://postbox.example/", "ask-too-large")
140+
val draft = QuestionAnswerDraft(
141+
selectedValues = List(MAX_QUESTION_DRAFT_SELECTED_VALUES + 1) { index -> "value-$index" },
142+
note = "still in memory"
143+
)
144+
val store = store()
145+
146+
assertEquals(
147+
QuestionDraftStoreResult.Failure(QuestionDraftStoreFailure.WRITE_FAILED),
148+
store.save(key, draft)
149+
)
150+
assertFalse(rootDirectory.walkTopDown().any { it.isFile })
151+
}
152+
153+
@Test
154+
fun missingKeyReturnsFiniteFailureAndRetrySaveRecovers() = runBlocking {
155+
val key = QuestionDraftKey("https://postbox.example/", "ask-key-loss")
156+
val original = QuestionAnswerDraft(listOf("one"), "before key loss")
157+
val recovered = QuestionAnswerDraft(listOf("other"), "after key loss")
158+
val store = store()
159+
assertEquals(QuestionDraftStoreResult.Success(Unit), store.save(key, original))
160+
KeyStore.getInstance("AndroidKeyStore").apply {
161+
load(null)
162+
deleteEntry(keyAlias)
163+
}
164+
165+
assertEquals(
166+
QuestionDraftStoreResult.Failure(QuestionDraftStoreFailure.KEY_INVALIDATED),
167+
store().load(key)
168+
)
169+
assertEquals(QuestionDraftStoreResult.Success(null), store().load(key))
170+
assertEquals(QuestionDraftStoreResult.Success(Unit), store().save(key, recovered))
171+
assertEquals(QuestionDraftStoreResult.Success(recovered), store().load(key))
172+
}
173+
174+
@Test
175+
fun failedAtomicCommitPreservesOldDraftAndLaterSaveRecovers() = runBlocking {
176+
val key = QuestionDraftKey("https://postbox.example/", "ask-atomic")
177+
val oldDraft = QuestionAnswerDraft(listOf("one"), "old draft")
178+
val newDraft = QuestionAnswerDraft(listOf("other"), "new draft")
179+
assertEquals(QuestionDraftStoreResult.Success(Unit), store().save(key, oldDraft))
180+
val failingStore = AndroidKeystoreQuestionDraftStore(
181+
rootDirectory = rootDirectory,
182+
keyAlias = keyAlias,
183+
atomicWriter = AndroidAtomicDraftWriter {
184+
throw IOException("simulated commit failure")
185+
}
186+
)
187+
188+
assertEquals(
189+
QuestionDraftStoreResult.Failure(QuestionDraftStoreFailure.WRITE_FAILED),
190+
failingStore.save(key, newDraft)
191+
)
192+
assertEquals(QuestionDraftStoreResult.Success(oldDraft), store().load(key))
193+
assertEquals(QuestionDraftStoreResult.Success(Unit), store().save(key, newDraft))
194+
assertEquals(QuestionDraftStoreResult.Success(newDraft), store().load(key))
195+
}
196+
197+
@Test
198+
fun deleteAndServerReconciliationRemoveOnlyTargetedDrafts() = runBlocking {
199+
val server = "https://postbox.example/"
200+
val keep = QuestionDraftKey(server, "ask-keep")
201+
val remove = QuestionDraftKey(server, "ask-remove")
202+
val otherServer = QuestionDraftKey("https://other.example/", "ask-remove")
203+
val draft = QuestionAnswerDraft(listOf("other"), "draft")
204+
val store = store()
205+
listOf(keep, remove, otherServer).forEach { key ->
206+
assertEquals(QuestionDraftStoreResult.Success(Unit), store.save(key, draft))
207+
}
208+
209+
assertEquals(
210+
QuestionDraftStoreResult.Success(Unit),
211+
store.reconcileServer(server, setOf(keep.requestId))
212+
)
213+
assertEquals(QuestionDraftStoreResult.Success(draft), store.load(keep))
214+
assertEquals(QuestionDraftStoreResult.Success(null), store.load(remove))
215+
assertEquals(QuestionDraftStoreResult.Success(draft), store.load(otherServer))
216+
217+
assertEquals(QuestionDraftStoreResult.Success(Unit), store.delete(keep))
218+
assertEquals(QuestionDraftStoreResult.Success(null), store.load(keep))
219+
assertTrue(rootDirectory.exists())
220+
assertNull(rootDirectory.listFiles()?.firstOrNull { it.name.contains("ask-keep") })
221+
}
222+
223+
private fun countDraftExclusions(resourceId: Int): Int {
224+
val parser = context.resources.getXml(resourceId)
225+
var count = 0
226+
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
227+
if (
228+
parser.eventType == XmlPullParser.START_TAG &&
229+
parser.name == "exclude" &&
230+
parser.getAttributeValue(null, "domain") == "root" &&
231+
parser.getAttributeValue(null, "path") == "no_backup/question_drafts_v1"
232+
) {
233+
count += 1
234+
}
235+
parser.next()
236+
}
237+
parser.close()
238+
return count
239+
}
240+
241+
private fun store() = AndroidKeystoreQuestionDraftStore(
242+
rootDirectory = rootDirectory,
243+
keyAlias = keyAlias
244+
)
245+
}

0 commit comments

Comments
 (0)