Add repeatable notification capture smoke tests

This commit is contained in:
ajp_anton
2026-07-23 22:04:22 +00:00
parent 50aebbd42b
commit 4d99c199dd
4 changed files with 165 additions and 16 deletions
+41
View File
@@ -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.
+7
View File
@@ -47,8 +47,15 @@ changes do. Disabling that setting records the updates as edits.
```bash ```bash
./gradlew test assembleDebug ./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 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 API 35/36, and LineageOS API 35/36 using clean LSPosed snapshots. Root and
Xposed are not used by this application. Xposed are not used by this application.
+1 -1
View File
@@ -1 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"><uses-permission android:name="android.permission.POST_NOTIFICATIONS"/><application android:theme="@style/Theme.AppCompat.DayNight"><activity android:name=".MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity></application></manifest> <manifest xmlns:android="http://schemas.android.com/apk/res/android"><uses-permission android:name="android.permission.POST_NOTIFICATIONS"/><application android:theme="@style/Theme.AppCompat.DayNight"><activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity></application></manifest>
@@ -1,23 +1,124 @@
package se.ajpanton.notificationlog.helper 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.graphics.Bitmap
import android.os.Bundle import android.os.Bundle
import android.widget.* import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
/**
* A debug companion app. Each button is also addressable with
* `adb shell am start -n .../.MainActivity --es helper_action <action>`.
*/
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private val manager by lazy { getSystemService(NotificationManager::class.java) } 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()) override fun onCreate(state: Bundle?) {
private fun messaging() { super.onCreate(state)
val me = android.app.Person.Builder().setName("Me").build() manager.createNotificationChannel(NotificationChannel(CHANNEL, "Test", NotificationManager.IMPORTANCE_DEFAULT))
val alice = android.app.Person.Builder().setName("Alice").build() 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) val style = Notification.MessagingStyle(me)
.addMessage("Hello from Alice", 1, alice) .addMessage("Hello from Alice", 1, alice)
.addMessage("A reply from me", 2, null as android.app.Person?) .addMessage("A reply from me", 2, null as Person?)
manager.notify(3, Notification.Builder(this, "test").setSmallIcon(android.R.drawable.ic_dialog_email).setStyle(style).build()) 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",
)
} }
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())
} }