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