Improve notification capture fidelity and documentation
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
# Notification Log
|
||||||
|
|
||||||
|
Notification Log is a private, non-root Android notification listener for API
|
||||||
|
34–36. It keeps an encrypted local history of notification appearances, edits,
|
||||||
|
and removal reasons, including a distinction between an app cancelling its own
|
||||||
|
notification and a user dismissing it.
|
||||||
|
|
||||||
|
## Privacy and capture limits
|
||||||
|
|
||||||
|
The event log and copied image files are encrypted at rest with an Android
|
||||||
|
Keystore-backed AES-GCM key. Export is deliberately unencrypted only after the
|
||||||
|
user acknowledges a warning and chooses a destination through Android's Storage
|
||||||
|
Access Framework.
|
||||||
|
|
||||||
|
Android exposes standard notification extras such as title, text, big text,
|
||||||
|
inbox lines, messaging-style messages, and readable big-picture content. This
|
||||||
|
app copies a readable bitmap at post time when Android provides one. It cannot
|
||||||
|
reconstruct opaque custom `RemoteViews`, OTP content Android redacts, or image
|
||||||
|
URIs/Icons which Android does not allow it to load. Such images remain marked
|
||||||
|
as `[image]` without causing the text event to be lost.
|
||||||
|
|
||||||
|
The app declares `QUERY_ALL_PACKAGES` solely to implement the settings pages'
|
||||||
|
“all installed apps” list. A Play-distributed build must meet Google Play's
|
||||||
|
restricted package-visibility policy and provide the required declaration; a
|
||||||
|
future distribution variant may need a narrower app-selection flow.
|
||||||
|
|
||||||
|
## Notification update policy
|
||||||
|
|
||||||
|
Each platform notification key is tracked independently. Group summaries are
|
||||||
|
logged as their own platform notifications; the app does not invent extra group
|
||||||
|
events. Ongoing notifications are recorded when Android posts, updates, or
|
||||||
|
removes them, but are never treated as user-dismissible. Identical reposts are
|
||||||
|
ignored. With the default **Ignore routine updates** setting, standard
|
||||||
|
progress/chronometer notifications do not create edit rows; non-routine content
|
||||||
|
changes do. Disabling that setting records the updates as edits.
|
||||||
|
|
||||||
|
## Development checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -12,6 +12,9 @@ import se.ajpanton.notificationlog.databinding.FragmentEventSettingsBinding
|
|||||||
import se.ajpanton.notificationlog.settings.AppRuleMode
|
import se.ajpanton.notificationlog.settings.AppRuleMode
|
||||||
import se.ajpanton.notificationlog.settings.LoggingRuleStore
|
import se.ajpanton.notificationlog.settings.LoggingRuleStore
|
||||||
import se.ajpanton.notificationlog.settings.LoggingType
|
import se.ajpanton.notificationlog.settings.LoggingType
|
||||||
|
import se.ajpanton.notificationlog.settings.AppListItem
|
||||||
|
import se.ajpanton.notificationlog.settings.AppListOrdering
|
||||||
|
import se.ajpanton.notificationlog.settings.ListedApp
|
||||||
import se.ajpanton.notificationlog.capture.SeenApps
|
import se.ajpanton.notificationlog.capture.SeenApps
|
||||||
|
|
||||||
class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
|
class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
|
||||||
@@ -99,31 +102,19 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
|
|||||||
val seen = SeenApps.snapshot()
|
val seen = SeenApps.snapshot()
|
||||||
val apps = requireContext().packageManager.getInstalledApplications(0)
|
val apps = requireContext().packageManager.getInstalledApplications(0)
|
||||||
.map { info ->
|
.map { info ->
|
||||||
AppRow(
|
ListedApp(
|
||||||
label = requireContext().packageManager.getApplicationLabel(info).toString(),
|
label = requireContext().packageManager.getApplicationLabel(info).toString(),
|
||||||
packageName = info.packageName,
|
packageName = info.packageName,
|
||||||
seen = info.packageName in seen,
|
seen = info.packageName in seen,
|
||||||
selected = info.packageName in rule.selectedPackages,
|
selected = info.packageName in rule.selectedPackages,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label })
|
AppListOrdering.items(apps, rule.onlySeenApps, rule.seenAppsFirst).forEach { item ->
|
||||||
val shown = if (rule.onlySeenApps) apps.filter { it.seen || it.selected } else apps
|
container.addView(if (item is AppListItem.App) appView(item.value) else separator())
|
||||||
val seenRows = shown.filter { it.seen }
|
|
||||||
val otherRows = shown.filterNot { it.seen }
|
|
||||||
if (!rule.onlySeenApps && rule.seenAppsFirst && seenRows.isNotEmpty()) {
|
|
||||||
seenRows.forEach { container.addView(appView(it)) }
|
|
||||||
if (otherRows.isNotEmpty()) container.addView(separator())
|
|
||||||
otherRows.forEach { container.addView(appView(it)) }
|
|
||||||
} else if (rule.onlySeenApps && seenRows.isNotEmpty() && otherRows.isNotEmpty()) {
|
|
||||||
seenRows.forEach { container.addView(appView(it)) }
|
|
||||||
container.addView(separator())
|
|
||||||
otherRows.forEach { container.addView(appView(it)) }
|
|
||||||
} else {
|
|
||||||
shown.forEach { container.addView(appView(it)) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun appView(row: AppRow): View = LinearLayout(requireContext()).apply {
|
private fun appView(row: ListedApp): View = LinearLayout(requireContext()).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
val checkbox = CheckBox(context).apply { isChecked = row.selected }
|
val checkbox = CheckBox(context).apply { isChecked = row.selected }
|
||||||
checkbox.setOnCheckedChangeListener { _, checked ->
|
checkbox.setOnCheckedChangeListener { _, checked ->
|
||||||
@@ -148,8 +139,6 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
|
|||||||
setBackgroundColor(0x33000000)
|
setBackgroundColor(0x33000000)
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class AppRow(val label: String, val packageName: String, val seen: Boolean, val selected: Boolean)
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val ARG_TITLE = "title"
|
private const val ARG_TITLE = "title"
|
||||||
private const val ARG_TYPE = "type"
|
private const val ARG_TYPE = "type"
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (row.entry.action == NotificationAction.EDITED && !row.entry.previousContents.isNullOrEmpty()) {
|
||||||
|
addView(TextView(context).apply {
|
||||||
|
text = "Previous: ${row.entry.previousContents}"
|
||||||
|
setLineSpacing(0f, 0.92f)
|
||||||
|
setPadding(0, dp(1), 0, dp(1))
|
||||||
|
})
|
||||||
|
}
|
||||||
row.imageBytes?.let { bytes ->
|
row.imageBytes?.let { bytes ->
|
||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { bitmap ->
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { bitmap ->
|
||||||
addView(ImageView(context).apply {
|
addView(ImageView(context).apply {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
override fun onListenerConnected() {
|
override fun onListenerConnected() {
|
||||||
super.onListenerConnected()
|
super.onListenerConnected()
|
||||||
getActiveNotifications()?.forEach { sbn ->
|
getActiveNotifications()?.forEach { sbn ->
|
||||||
val snapshot = NotificationContents.snapshot(sbn)
|
val snapshot = NotificationContents.snapshot(sbn, this)
|
||||||
SeenApps.markSeen(snapshot.packageName)
|
SeenApps.markSeen(snapshot.packageName)
|
||||||
activeNotifications[snapshot.key] = snapshot
|
activeNotifications[snapshot.key] = snapshot
|
||||||
record(snapshot, NotificationAction.ALREADY_ACTIVE, LoggingType.APPEARING, includeContents = true)
|
record(snapshot, NotificationAction.ALREADY_ACTIVE, LoggingType.APPEARING, includeContents = true)
|
||||||
@@ -43,14 +43,14 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
||||||
val snapshot = NotificationContents.snapshot(sbn)
|
val snapshot = NotificationContents.snapshot(sbn, this)
|
||||||
SeenApps.markSeen(snapshot.packageName)
|
SeenApps.markSeen(snapshot.packageName)
|
||||||
val previous = activeNotifications.put(snapshot.key, snapshot)
|
val previous = activeNotifications.put(snapshot.key, snapshot)
|
||||||
when {
|
when {
|
||||||
previous == null -> record(snapshot, NotificationAction.APPEARED, LoggingType.APPEARING, includeContents = true)
|
previous == null -> record(snapshot, NotificationAction.APPEARED, LoggingType.APPEARING, includeContents = true)
|
||||||
(previous.textContents != snapshot.textContents || previous.hasImage != snapshot.hasImage) &&
|
NotificationChangeClassifier.isMeaningfulEdit(previous, snapshot) &&
|
||||||
!(snapshot.isRoutine && ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates) ->
|
!NotificationChangeClassifier.shouldIgnoreEdit(previous, snapshot, ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates) ->
|
||||||
record(snapshot, NotificationAction.EDITED, LoggingType.EDITS, includeContents = true)
|
record(snapshot, NotificationAction.EDITED, LoggingType.EDITS, includeContents = true, previousSnapshot = previous)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
rankingMap: RankingMap,
|
rankingMap: RankingMap,
|
||||||
reason: Int,
|
reason: Int,
|
||||||
) {
|
) {
|
||||||
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn)
|
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn, this)
|
||||||
SeenApps.markSeen(snapshot.packageName)
|
SeenApps.markSeen(snapshot.packageName)
|
||||||
record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false)
|
record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false)
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
action: NotificationAction,
|
action: NotificationAction,
|
||||||
loggingType: LoggingType,
|
loggingType: LoggingType,
|
||||||
includeContents: Boolean,
|
includeContents: Boolean,
|
||||||
|
previousSnapshot: NotificationSnapshot? = null,
|
||||||
) {
|
) {
|
||||||
if (!NotificationRuleEvaluator.allows(ruleStore.ruleFor(loggingType), snapshot.packageName)) return
|
if (!NotificationRuleEvaluator.allows(ruleStore.ruleFor(loggingType), snapshot.packageName)) return
|
||||||
val appName = appName(snapshot.packageName)
|
val appName = appName(snapshot.packageName)
|
||||||
@@ -86,6 +87,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
appName = appName,
|
appName = appName,
|
||||||
action = action,
|
action = action,
|
||||||
contents = if (includeContents) visibleContents(snapshot) else null,
|
contents = if (includeContents) visibleContents(snapshot) else null,
|
||||||
|
previousContents = previousSnapshot?.let(::visibleContents),
|
||||||
imageId = if (retainImage) java.util.UUID.randomUUID().toString() else null,
|
imageId = if (retainImage) java.util.UUID.randomUUID().toString() else null,
|
||||||
)
|
)
|
||||||
writeExecutor.execute {
|
writeExecutor.execute {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package se.ajpanton.notificationlog.capture
|
||||||
|
|
||||||
|
/** Keeps update classification deterministic and independently testable. */
|
||||||
|
object NotificationChangeClassifier {
|
||||||
|
fun isMeaningfulEdit(previous: NotificationSnapshot, current: NotificationSnapshot): Boolean =
|
||||||
|
previous.textContents != current.textContents || previous.hasImage != current.hasImage
|
||||||
|
|
||||||
|
fun shouldIgnoreEdit(previous: NotificationSnapshot, current: NotificationSnapshot, ignoreRoutineUpdates: Boolean): Boolean =
|
||||||
|
ignoreRoutineUpdates && current.isRoutine && isMeaningfulEdit(previous, current)
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
package se.ajpanton.notificationlog.capture
|
package se.ajpanton.notificationlog.capture
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.service.notification.StatusBarNotification
|
import android.service.notification.StatusBarNotification
|
||||||
|
|
||||||
@@ -32,24 +37,51 @@ object NotificationContents {
|
|||||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(" — ")
|
return parts.takeIf { it.isNotEmpty() }?.joinToString(" — ")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun snapshot(sbn: StatusBarNotification) = NotificationSnapshot(
|
fun snapshot(sbn: StatusBarNotification, context: Context): NotificationSnapshot {
|
||||||
|
val notification = sbn.notification
|
||||||
|
val extras = notification.extras
|
||||||
|
val picture = extras?.getParcelable(Notification.EXTRA_PICTURE, Bitmap::class.java)
|
||||||
|
val pictureIcon = extras?.getParcelable(Notification.EXTRA_PICTURE_ICON, Icon::class.java)
|
||||||
|
return NotificationSnapshot(
|
||||||
key = sbn.key,
|
key = sbn.key,
|
||||||
packageName = sbn.packageName,
|
packageName = sbn.packageName,
|
||||||
textContents = extract(sbn.notification),
|
textContents = extract(notification),
|
||||||
hasImage = sbn.notification.extras?.let {
|
hasImage = extras?.let {
|
||||||
it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON)
|
it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON)
|
||||||
} == true,
|
} == true,
|
||||||
isRoutine = sbn.notification.extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true ||
|
isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true ||
|
||||||
sbn.notification.extras?.containsKey(Notification.EXTRA_PROGRESS) == true,
|
extras?.containsKey(Notification.EXTRA_PROGRESS) == true,
|
||||||
imageBytes = sbn.notification.extras?.getParcelable(Notification.EXTRA_PICTURE, Bitmap::class.java)?.let { bitmap ->
|
imageBytes = picture?.toPng() ?: pictureIcon?.let { icon -> icon.loadDrawable(context)?.toBitmap()?.toPng() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Drawable.toBitmap(): Bitmap? = when (this) {
|
||||||
|
is BitmapDrawable -> bitmap
|
||||||
|
else -> runCatching {
|
||||||
|
val sourceWidth = maxOf(1, intrinsicWidth)
|
||||||
|
val sourceHeight = maxOf(1, intrinsicHeight)
|
||||||
|
val scale = minOf(1f, MAX_IMAGE_DIMENSION.toFloat() / maxOf(sourceWidth, sourceHeight))
|
||||||
|
val width = (sourceWidth * scale).toInt()
|
||||||
|
val height = (sourceHeight * scale).toInt()
|
||||||
|
Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmap ->
|
||||||
|
setBounds(0, 0, width, height)
|
||||||
|
draw(Canvas(bitmap))
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Bitmap.toPng(): ByteArray? = runCatching {
|
||||||
|
val scale = minOf(1f, MAX_IMAGE_DIMENSION.toFloat() / maxOf(width, height))
|
||||||
|
val bitmap = if (scale < 1f) Bitmap.createScaledBitmap(this, (width * scale).toInt(), (height * scale).toInt(), true) else this
|
||||||
java.io.ByteArrayOutputStream().use { output ->
|
java.io.ByteArrayOutputStream().use { output ->
|
||||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
|
||||||
output.toByteArray()
|
output.toByteArray()
|
||||||
}
|
}
|
||||||
},
|
}.getOrNull()
|
||||||
)
|
|
||||||
|
|
||||||
private fun CharSequence.addTo(parts: MutableSet<String>) {
|
private fun CharSequence.addTo(parts: MutableSet<String>) {
|
||||||
toString().trim().takeIf { it.isNotEmpty() }?.let(parts::add)
|
toString().trim().takeIf { it.isNotEmpty() }?.let(parts::add)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val MAX_IMAGE_DIMENSION = 1600
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ object NotificationLogEntryJson {
|
|||||||
.put("appName", entry.appName)
|
.put("appName", entry.appName)
|
||||||
.put("action", entry.action.name)
|
.put("action", entry.action.name)
|
||||||
.put("contents", entry.contents)
|
.put("contents", entry.contents)
|
||||||
|
.put("previousContents", entry.previousContents)
|
||||||
.put("imageId", entry.imageId),
|
.put("imageId", entry.imageId),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -35,6 +36,7 @@ object NotificationLogEntryJson {
|
|||||||
appName = entry.getString("appName"),
|
appName = entry.getString("appName"),
|
||||||
action = NotificationAction.valueOf(entry.getString("action")),
|
action = NotificationAction.valueOf(entry.getString("action")),
|
||||||
contents = if (entry.isNull("contents")) null else entry.getString("contents"),
|
contents = if (entry.isNull("contents")) null else entry.getString("contents"),
|
||||||
|
previousContents = entry.optString("previousContents").takeIf { it.isNotEmpty() },
|
||||||
imageId = entry.optString("imageId").takeIf { it.isNotEmpty() },
|
imageId = entry.optString("imageId").takeIf { it.isNotEmpty() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ data class NotificationLogEntry(
|
|||||||
val appName: String,
|
val appName: String,
|
||||||
val action: NotificationAction,
|
val action: NotificationAction,
|
||||||
val contents: String?,
|
val contents: String?,
|
||||||
|
/** The prior readable contents for an edit event, when that data was allowed to be logged. */
|
||||||
|
val previousContents: String? = null,
|
||||||
/** ID of a private encrypted PNG copy, when this event retained a readable image. */
|
/** ID of a private encrypted PNG copy, when this event retained a readable image. */
|
||||||
val imageId: String? = null,
|
val imageId: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package se.ajpanton.notificationlog.settings
|
||||||
|
|
||||||
|
data class ListedApp(val label: String, val packageName: String, val seen: Boolean, val selected: Boolean)
|
||||||
|
sealed interface AppListItem {
|
||||||
|
data class App(val value: ListedApp) : AppListItem
|
||||||
|
data object Separator : AppListItem
|
||||||
|
}
|
||||||
|
|
||||||
|
object AppListOrdering {
|
||||||
|
fun items(allApps: List<ListedApp>, onlySeen: Boolean, seenAppsFirst: Boolean): List<AppListItem> {
|
||||||
|
val apps = allApps.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label })
|
||||||
|
val shown = if (onlySeen) apps.filter { it.seen || it.selected } else apps
|
||||||
|
val seen = shown.filter { it.seen }
|
||||||
|
val other = shown.filterNot { it.seen }
|
||||||
|
val separate = (onlySeen || seenAppsFirst) && seen.isNotEmpty() && other.isNotEmpty()
|
||||||
|
return buildList {
|
||||||
|
if (separate) {
|
||||||
|
seen.forEach { add(AppListItem.App(it)) }
|
||||||
|
add(AppListItem.Separator)
|
||||||
|
other.forEach { add(AppListItem.App(it)) }
|
||||||
|
} else {
|
||||||
|
shown.forEach { add(AppListItem.App(it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,12 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Clear logs" />
|
android:text="Clear logs" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:text="Capture limits: standard title, text, messaging, inbox, big-text, and readable big-picture content can be logged. Android may withhold OTPs, custom notification layouts, or image data; those cannot be recovered. App lists require broad installed-app visibility and may need a Play policy declaration for distribution." />
|
||||||
|
|
||||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
android:id="@+id/app_lock"
|
android:id="@+id/app_lock"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package se.ajpanton.notificationlog.capture
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class NotificationChangeClassifierTest {
|
||||||
|
private fun snapshot(text: String, routine: Boolean = false) = NotificationSnapshot(
|
||||||
|
key = "key", packageName = "example.app", textContents = text, hasImage = false,
|
||||||
|
isRoutine = routine, imageBytes = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test fun `text change is an edit`() {
|
||||||
|
assertTrue(NotificationChangeClassifier.isMeaningfulEdit(snapshot("old"), snapshot("new")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `identical repost is not an edit`() {
|
||||||
|
assertFalse(NotificationChangeClassifier.isMeaningfulEdit(snapshot("same"), snapshot("same")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `routine edit respects ignore setting`() {
|
||||||
|
assertTrue(NotificationChangeClassifier.shouldIgnoreEdit(snapshot("old"), snapshot("new", routine = true), true))
|
||||||
|
assertFalse(NotificationChangeClassifier.shouldIgnoreEdit(snapshot("old"), snapshot("new", routine = true), false))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package se.ajpanton.notificationlog.settings
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AppListOrderingTest {
|
||||||
|
private fun app(label: String, seen: Boolean = false, selected: Boolean = false) = ListedApp(label, "pkg.$label", seen, selected)
|
||||||
|
|
||||||
|
@Test fun `seen apps are first with a separator when requested`() {
|
||||||
|
val items = AppListOrdering.items(listOf(app("Zulu"), app("Beta", seen = true), app("Alpha", seen = true)), false, true)
|
||||||
|
assertEquals(listOf("Alpha", "Beta", "|", "Zulu"), items.map { if (it is AppListItem.App) it.value.label else "|" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `selected unseen app remains after seen apps in only-seen mode`() {
|
||||||
|
val items = AppListOrdering.items(listOf(app("Seen", seen = true), app("Chosen", selected = true), app("Hidden")), true, true)
|
||||||
|
assertEquals(listOf("Seen", "|", "Chosen"), items.map { if (it is AppListItem.App) it.value.label else "|" })
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user