diff --git a/DEVICE_TEST.md b/DEVICE_TEST.md
new file mode 100644
index 0000000..6a28b68
--- /dev/null
+++ b/DEVICE_TEST.md
@@ -0,0 +1,41 @@
+# Physical-device acceptance checklist
+
+Use a current build of both `app` and `test-helper` on the target phone. Grant
+the helper's notification permission and grant Notification Log notification
+listener access before starting. Keep the default logging rules for the first
+pass.
+
+1. Open the helper and tap **Post text**, then **Edit text**. The log should
+ show an appearance followed by an edit, with the expanded edit showing the
+ prior text.
+2. Tap **Post messaging**, **Post big text**, **Post inbox**, and **Post
+ image**. Confirm the visible text is captured and that the image row shows
+ `[image]`; expand the image row and export HTML to confirm its support file
+ is included.
+3. Tap **Post dismissible**, pull down the system notification shade, and
+ swipe that one notification away. Confirm its removal is logged as a user
+ dismissal. Tap **Post text**, then **Cancel text**, and confirm the removal
+ is instead app-cancelled. Also let **Post timeout** expire and check that it
+ is identified as a timeout.
+4. Tap **Post progress**, **Update progress**, and **Post chronometer**. With
+ **Ignore routine updates** enabled, only their initial appearances should be
+ logged. Disable it under Edits, repeat the updates, and confirm edits are
+ logged.
+5. Tap **Post group** and dismiss its children/summary from the shade. Check
+ the actual removal reasons shown by the phone; OEMs may coalesce groups
+ differently, but the app must not label an app cancellation as a user swipe.
+6. In Settings, exercise the five master toggles, copy settings, list filters,
+ pull-to-refresh at both ends, export formats, and clearing. Disable all five
+ logging types, reboot, and check that the listener is not active; enable one
+ again, reboot, and post another helper notification to confirm reconnection.
+7. Enable **Lock app with phone unlock**, leave and return to the app, and test
+ both a strong biometric and the device credential fallback. Cancelling the
+ prompt must leave the app rather than expose logs.
+8. Test light and dark themes, portrait/landscape, folded/unfolded/narrow/wide
+ layouts. Confirm there is no content under a system bar and that the sidebar
+ stays permanently visible at the wide breakpoint.
+
+The AOSP smoke script covers the deterministic helper event generation,
+encrypted persistence, readable image retention, and post-reboot listener
+reconnection. Steps involving Samsung's shade, its removal-reason behavior,
+biometrics, and foldable geometry require this physical pass.
diff --git a/README.md b/README.md
index 2594ef6..df767fd 100644
--- a/README.md
+++ b/README.md
@@ -47,8 +47,15 @@ changes do. Disabling that setting records the updates as edits.
```bash
./gradlew test assembleDebug
+./scripts/run-aosp-api36-notification-smoke.sh
```
+The AOSP API 36 smoke scenario starts from a clean emulator snapshot, grants
+listener access, drives all helper notification variants, verifies encrypted
+event and image persistence, and verifies that the listener records another
+event after an emulator reboot. It deliberately leaves user swipe dismissal,
+biometric lock behavior, and OEM/foldable rendering to device testing.
+
The local Android test lab has been checked on AOSP API 34/35/36, Google APIs
API 35/36, and LineageOS API 35/36 using clean LSPosed snapshots. Root and
Xposed are not used by this application.
diff --git a/test-helper/src/main/AndroidManifest.xml b/test-helper/src/main/AndroidManifest.xml
index 02be513..20a787f 100644
--- a/test-helper/src/main/AndroidManifest.xml
+++ b/test-helper/src/main/AndroidManifest.xml
@@ -1 +1 @@
-
+
diff --git a/test-helper/src/main/java/se/ajpanton/notificationlog/helper/MainActivity.kt b/test-helper/src/main/java/se/ajpanton/notificationlog/helper/MainActivity.kt
index ae943d5..8ab01ac 100644
--- a/test-helper/src/main/java/se/ajpanton/notificationlog/helper/MainActivity.kt
+++ b/test-helper/src/main/java/se/ajpanton/notificationlog/helper/MainActivity.kt
@@ -1,23 +1,124 @@
package se.ajpanton.notificationlog.helper
-import android.app.*
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Person
+import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
-import android.widget.*
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.ScrollView
import androidx.appcompat.app.AppCompatActivity
+/**
+ * A debug companion app. Each button is also addressable with
+ * `adb shell am start -n .../.MainActivity --es helper_action `.
+ */
class MainActivity : AppCompatActivity() {
- private val manager by lazy { getSystemService(NotificationManager::class.java) }
- override fun onCreate(state: Bundle?) { super.onCreate(state); manager.createNotificationChannel(NotificationChannel("test", "Test", NotificationManager.IMPORTANCE_DEFAULT)); setContentView(LinearLayout(this).apply { orientation=LinearLayout.VERTICAL; listOf("Post text" to { post("Text message") }, "Edit text" to { post("Edited message") }, "Post messaging" to { messaging() }, "Post image" to { image() }, "Post dismissible" to { dismissible() }, "Cancel text" to { manager.cancel(1) }).forEach { (label, action) -> addView(Button(this@MainActivity).apply { text=label; setOnClickListener { action() } }) } }) }
- private fun post(text:String)=manager.notify(1, Notification.Builder(this,"test").setSmallIcon(android.R.drawable.ic_dialog_info).setContentTitle("Helper").setContentText(text).build())
- private fun messaging() {
- val me = android.app.Person.Builder().setName("Me").build()
- val alice = android.app.Person.Builder().setName("Alice").build()
- val style = Notification.MessagingStyle(me)
- .addMessage("Hello from Alice", 1, alice)
- .addMessage("A reply from me", 2, null as android.app.Person?)
- manager.notify(3, Notification.Builder(this, "test").setSmallIcon(android.R.drawable.ic_dialog_email).setStyle(style).build())
- }
- private fun image(){ val b=Bitmap.createBitmap(20,20,Bitmap.Config.ARGB_8888).apply{eraseColor(0xffff0000.toInt())}; manager.notify(2,Notification.Builder(this,"test").setSmallIcon(android.R.drawable.ic_menu_gallery).setStyle(Notification.BigPictureStyle().bigPicture(b)).build()) }
- private fun dismissible()=manager.notify(4, Notification.Builder(this,"test").setSmallIcon(android.R.drawable.ic_dialog_info).setContentTitle("Dismiss me manually").setContentText("Swipe this notification away to test user dismissal.").setAutoCancel(true).build())
+ private val manager by lazy { getSystemService(NotificationManager::class.java) }
+
+ override fun onCreate(state: Bundle?) {
+ super.onCreate(state)
+ manager.createNotificationChannel(NotificationChannel(CHANNEL, "Test", NotificationManager.IMPORTANCE_DEFAULT))
+ setContentView(ScrollView(this).apply {
+ addView(LinearLayout(this@MainActivity).apply {
+ orientation = LinearLayout.VERTICAL
+ ACTIONS.forEach { (label, action) ->
+ addView(Button(this@MainActivity).apply {
+ text = label
+ setOnClickListener { perform(action) }
+ })
+ }
+ })
+ })
+ intent.getStringExtra(EXTRA_ACTION)?.let(::perform)
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ intent.getStringExtra(EXTRA_ACTION)?.let(::perform)
+ }
+
+ private fun perform(action: String) {
+ when (action) {
+ "post_text" -> postText("Text message")
+ "edit_text" -> postText("Edited message")
+ "messaging" -> postMessaging()
+ "image" -> postImage()
+ "dismissible" -> postDismissible()
+ "big_text" -> postBigText()
+ "inbox" -> postInbox()
+ "progress" -> postProgress(25)
+ "progress_update" -> postProgress(75)
+ "chronometer" -> postChronometer()
+ "timeout" -> postTimeout()
+ "group" -> postGroup()
+ "cancel_text" -> manager.cancel(TEXT_ID)
+ "cancel_all" -> manager.cancelAll()
+ }
+ }
+
+ private fun base() = Notification.Builder(this, CHANNEL).setSmallIcon(android.R.drawable.ic_dialog_info)
+
+ private fun postText(text: String) = manager.notify(TEXT_ID, base().setContentTitle("Helper").setContentText(text).build())
+
+ private fun postMessaging() {
+ val me = Person.Builder().setName("Me").build()
+ val alice = Person.Builder().setName("Alice").build()
+ val style = Notification.MessagingStyle(me)
+ .addMessage("Hello from Alice", 1, alice)
+ .addMessage("A reply from me", 2, null as Person?)
+ manager.notify(MESSAGING_ID, base().setSmallIcon(android.R.drawable.ic_dialog_email).setStyle(style).build())
+ }
+
+ private fun postImage() {
+ val image = Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888).apply { eraseColor(0xffff0000.toInt()) }
+ manager.notify(IMAGE_ID, base().setSmallIcon(android.R.drawable.ic_menu_gallery).setStyle(Notification.BigPictureStyle().bigPicture(image)).build())
+ }
+
+ private fun postDismissible() = manager.notify(DISMISSIBLE_ID, base().setContentTitle("Dismiss me manually").setContentText("Swipe this notification away to test user dismissal.").setAutoCancel(true).build())
+
+ private fun postBigText() = manager.notify(BIG_TEXT_ID, base().setStyle(Notification.BigTextStyle().bigText("A long notification body used to exercise Notification.EXTRA_BIG_TEXT.".repeat(3))).build())
+
+ private fun postInbox() = manager.notify(INBOX_ID, base().setStyle(Notification.InboxStyle().addLine("First inbox line").addLine("Second inbox line")).build())
+
+ private fun postProgress(progress: Int) = manager.notify(PROGRESS_ID, base().setContentTitle("Download").setContentText("$progress% complete").setOnlyAlertOnce(true).setProgress(100, progress, false).build())
+
+ private fun postChronometer() = manager.notify(CHRONOMETER_ID, base().setContentTitle("Timer").setWhen(System.currentTimeMillis()).setShowWhen(true).setUsesChronometer(true).build())
+
+ private fun postTimeout() = manager.notify(TIMEOUT_ID, base().setContentTitle("Timeout test").setContentText("This should expire automatically.").setTimeoutAfter(2_000).build())
+
+ private fun postGroup() {
+ manager.notify(GROUP_CHILD_ONE, base().setContentText("Grouped child one").setGroup(GROUP_KEY).build())
+ manager.notify(GROUP_CHILD_TWO, base().setContentText("Grouped child two").setGroup(GROUP_KEY).build())
+ manager.notify(GROUP_SUMMARY, base().setContentTitle("Grouped summary").setGroup(GROUP_KEY).setGroupSummary(true).build())
+ }
+
+ private companion object {
+ const val CHANNEL = "test"
+ const val EXTRA_ACTION = "helper_action"
+ const val TEXT_ID = 1
+ const val IMAGE_ID = 2
+ const val MESSAGING_ID = 3
+ const val DISMISSIBLE_ID = 4
+ const val BIG_TEXT_ID = 5
+ const val INBOX_ID = 6
+ const val PROGRESS_ID = 7
+ const val CHRONOMETER_ID = 8
+ const val TIMEOUT_ID = 9
+ const val GROUP_CHILD_ONE = 10
+ const val GROUP_CHILD_TWO = 11
+ const val GROUP_SUMMARY = 12
+ const val GROUP_KEY = "helper-group"
+ val ACTIONS = listOf(
+ "Post text" to "post_text", "Edit text" to "edit_text", "Post messaging" to "messaging",
+ "Post image" to "image", "Post dismissible" to "dismissible", "Post big text" to "big_text",
+ "Post inbox" to "inbox", "Post progress" to "progress", "Update progress" to "progress_update",
+ "Post chronometer" to "chronometer", "Post timeout" to "timeout", "Post group" to "group",
+ "Cancel text" to "cancel_text", "Cancel all" to "cancel_all",
+ )
+ }
}