v1.0.0-beta06: Auto-action rules, deletion tracking, test notifications

Auto-action filter rules (EXECUTE_ACTION):
- New filter action that automatically taps notification action buttons
- Finds target button by label (e.g. "Delete", "Archive") on incoming notifications
- Executes PendingIntent if found, logs failure note if button missing
- "Also delete from list" option when action succeeds
- Full UI in filter rule editor with button label field and checkbox

Deletion reason tracking:
- New deletion_reason column (DB v6→v7 migration)
- Tracks why each notification was deleted: USER, USER_BULK, AUTO_ACTION, RETENTION
- Auto-action success/failure details stored with rule name and action label
- Detail screen shows colored status banner for auto-action results
- Deletion reason visible in Info for Nerds section

Test notification broadcast (dev flavor only):
- SEND_NOTIFICATION posts real Android notifications via NotificationManager
- Supports action buttons, reply actions with RemoteInput
- Notification styles: messaging, bigpicture, bigtext, inbox
- Generated test bitmaps for sticker/image testing
- Action confirmation notifications show reply text in timeline
- POST_NOTIFICATIONS permission added to dev manifest only

Detail screen improvements:
- Icons on live action chips (Delete→trash, Archive→box, Like→heart, etc.)
- Reply dialog auto-focuses text field and opens keyboard
- Taller reply text field (3 lines)

Code quality:
- Renamed CLAUDE_TEST tag to CLAUDE_SNI_TEST across all files
- Fixed deprecated Icons.Default.OpenInNew → AutoMirrored in DetailScreen + SwipeableNotificationItem
- Fixed deprecated Icons.Default.Backspace → AutoMirrored in LockScreen
- Fixed deprecated fallbackToDestructiveMigration() → added dropAllTables param
- Wrapped BitmapFactory.decodeFile() in runCatching to prevent crashes on corrupt files
- Removed unused Notifications icon import
- Added translations for all new strings (ES, FR, DE, CA, SV)

Published as internal test on Google Play Store.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-03-18 14:20:49 +01:00
parent 327e846478
commit a0fc459e7a
27 changed files with 1056 additions and 70 deletions

View file

@ -83,17 +83,17 @@ The **testing** flavor is your primary development target. It has all pro featur
```bash
# Dump all visible UI element positions → logcat
adb shell am broadcast -a com.roundingmobile.sni.test.DUMP_UI
adb logcat -d -s CLAUDE_TEST:V
adb logcat -d -s CLAUDE_SNI_TEST:V
# Dump app state → logcat
adb shell am broadcast -a com.roundingmobile.sni.test.DUMP_STATE
adb logcat -d -s CLAUDE_TEST:V
adb logcat -d -s CLAUDE_SNI_TEST:V
# Stream test logs live
adb logcat -c && adb logcat -s CLAUDE_TEST:V
adb logcat -c && adb logcat -s CLAUDE_SNI_TEST:V
# Check for crashes
adb logcat -d | grep -E "(CLAUDE_TEST|AndroidRuntime|FATAL)"
adb logcat -d | grep -E "(CLAUDE_SNI_TEST|AndroidRuntime|FATAL)"
```
### Interacting
@ -155,7 +155,7 @@ adb shell am broadcast -a com.roundingmobile.sni.test.TRIGGER_DIGEST
Never guess coordinates. Always:
1. `adb shell am broadcast -a com.roundingmobile.sni.test.DUMP_UI`
2. `adb logcat -d -s CLAUDE_TEST:V` → parse VIEW lines
2. `adb logcat -d -s CLAUDE_SNI_TEST:V` → parse VIEW lines
3. Calculate center of target element: `x = (left+right)/2, y = (top+bottom)/2`
4. `adb shell input tap x y`
@ -211,7 +211,7 @@ Create the project structure with:
**Verify:**
1. Build and install testing flavor
2. `adb shell am broadcast -a com.roundingmobile.sni.test.DUMP_UI`
3. `adb logcat -d -s CLAUDE_TEST:V` → should see VIEW lines for bottom nav items
3. `adb logcat -d -s CLAUDE_SNI_TEST:V` → should see VIEW lines for bottom nav items
4. Build free flavor → verify it compiles (no-op instrumentation)
5. Build pro flavor → verify it compiles
@ -548,7 +548,7 @@ adb shell cmd notification allow_listener \
com.roundingmobile.sni.testing.debug/com.roundingmobile.sni.service.NotificationCaptureService
```
### No CLAUDE_TEST output
### No CLAUDE_SNI_TEST output
- Verify testing flavor installed: `adb shell pm list packages | grep testing`
- App running: `adb shell pidof com.roundingmobile.sni.testing.debug`
- Try DUMP_STATE and wait before reading logcat

View file

@ -0,0 +1,406 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "91777b47b6e4ff14b42da354ff960de4",
"entities": [
{
"tableName": "notifications",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `package_name` TEXT NOT NULL, `app_name` TEXT NOT NULL, `title` TEXT, `text` TEXT, `big_text` TEXT, `category` TEXT, `priority` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `notification_id` INTEGER NOT NULL, `notification_tag` TEXT, `is_update` INTEGER NOT NULL, `previous_version_id` INTEGER, `is_removed` INTEGER NOT NULL, `removed_at` INTEGER, `removal_delay_ms` INTEGER, `is_bookmarked` INTEGER NOT NULL, `is_read` INTEGER NOT NULL, `extras_json` TEXT, `actions_json` TEXT, `icon_uri` TEXT, `created_at` INTEGER NOT NULL, `deleted_at` INTEGER, `deletion_reason` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "appName",
"columnName": "app_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT"
},
{
"fieldPath": "bigText",
"columnName": "big_text",
"affinity": "TEXT"
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT"
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationId",
"columnName": "notification_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationTag",
"columnName": "notification_tag",
"affinity": "TEXT"
},
{
"fieldPath": "isUpdate",
"columnName": "is_update",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "previousVersionId",
"columnName": "previous_version_id",
"affinity": "INTEGER"
},
{
"fieldPath": "isRemoved",
"columnName": "is_removed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "removedAt",
"columnName": "removed_at",
"affinity": "INTEGER"
},
{
"fieldPath": "removalDelayMs",
"columnName": "removal_delay_ms",
"affinity": "INTEGER"
},
{
"fieldPath": "isBookmarked",
"columnName": "is_bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "is_read",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "extrasJson",
"columnName": "extras_json",
"affinity": "TEXT"
},
{
"fieldPath": "actionsJson",
"columnName": "actions_json",
"affinity": "TEXT"
},
{
"fieldPath": "iconUri",
"columnName": "icon_uri",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deletedAt",
"columnName": "deleted_at",
"affinity": "INTEGER"
},
{
"fieldPath": "deletionReason",
"columnName": "deletion_reason",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_notifications_package_name",
"unique": false,
"columnNames": [
"package_name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_package_name` ON `${TABLE_NAME}` (`package_name`)"
},
{
"name": "index_notifications_timestamp",
"unique": false,
"columnNames": [
"timestamp"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_timestamp` ON `${TABLE_NAME}` (`timestamp`)"
},
{
"name": "index_notifications_category",
"unique": false,
"columnNames": [
"category"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_category` ON `${TABLE_NAME}` (`category`)"
},
{
"name": "index_notifications_is_bookmarked",
"unique": false,
"columnNames": [
"is_bookmarked"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_is_bookmarked` ON `${TABLE_NAME}` (`is_bookmarked`)"
},
{
"name": "index_notifications_package_name_timestamp",
"unique": false,
"columnNames": [
"package_name",
"timestamp"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_package_name_timestamp` ON `${TABLE_NAME}` (`package_name`, `timestamp`)"
},
{
"name": "index_notifications_deleted_at",
"unique": false,
"columnNames": [
"deleted_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_deleted_at` ON `${TABLE_NAME}` (`deleted_at`)"
}
]
},
{
"tableName": "notifications_fts",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`title` TEXT, `text` TEXT, `big_text` TEXT, content=`notifications`)",
"fields": [
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT"
},
{
"fieldPath": "bigText",
"columnName": "big_text",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "notifications",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_BEFORE_UPDATE BEFORE UPDATE ON `notifications` BEGIN DELETE FROM `notifications_fts` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_BEFORE_DELETE BEFORE DELETE ON `notifications` BEGIN DELETE FROM `notifications_fts` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_AFTER_UPDATE AFTER UPDATE ON `notifications` BEGIN INSERT INTO `notifications_fts`(`docid`, `title`, `text`, `big_text`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`text`, NEW.`big_text`); END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_notifications_fts_AFTER_INSERT AFTER INSERT ON `notifications` BEGIN INSERT INTO `notifications_fts`(`docid`, `title`, `text`, `big_text`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`text`, NEW.`big_text`); END"
]
},
{
"tableName": "hidden_apps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `hidden_at` INTEGER NOT NULL, PRIMARY KEY(`package_name`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "hiddenAt",
"columnName": "hidden_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"package_name"
]
}
},
{
"tableName": "app_filter_prefs",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `is_visible` INTEGER NOT NULL, PRIMARY KEY(`package_name`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isVisible",
"columnName": "is_visible",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"package_name"
]
}
},
{
"tableName": "filter_rules",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `action` TEXT NOT NULL, `match_field` TEXT NOT NULL, `match_type` TEXT NOT NULL, `pattern` TEXT NOT NULL, `conditions_json` TEXT, `condition_operator` TEXT NOT NULL, `action_params` TEXT, `package_name` TEXT, `app_name` TEXT, `is_enabled` INTEGER NOT NULL, `is_built_in` INTEGER NOT NULL, `created_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "action",
"columnName": "action",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "matchField",
"columnName": "match_field",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "matchType",
"columnName": "match_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pattern",
"columnName": "pattern",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "conditionsJson",
"columnName": "conditions_json",
"affinity": "TEXT"
},
{
"fieldPath": "conditionOperator",
"columnName": "condition_operator",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actionParams",
"columnName": "action_params",
"affinity": "TEXT"
},
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT"
},
{
"fieldPath": "appName",
"columnName": "app_name",
"affinity": "TEXT"
},
{
"fieldPath": "isEnabled",
"columnName": "is_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isBuiltIn",
"columnName": "is_built_in",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_filter_rules_is_enabled",
"unique": false,
"columnNames": [
"is_enabled"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_filter_rules_is_enabled` ON `${TABLE_NAME}` (`is_enabled`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '91777b47b6e4ff14b42da354ff960de4')"
]
}
}

View file

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required for SEND_NOTIFICATION test broadcast (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<receiver
android:name="com.roundingmobile.sni.instrumentation.TestBroadcastReceiver"
@ -14,6 +17,8 @@
<action android:name="com.roundingmobile.sni.test.INJECT_NOTIFICATION" />
<action android:name="com.roundingmobile.sni.test.INJECT_BATCH" />
<action android:name="com.roundingmobile.sni.test.REMOVE_NOTIFICATION" />
<action android:name="com.roundingmobile.sni.test.SEND_NOTIFICATION" />
<action android:name="com.roundingmobile.sni.test.ACTION_CLICKED" />
<action android:name="com.roundingmobile.sni.test.TRIGGER_CLEANUP" />
<action android:name="com.roundingmobile.sni.test.TRIGGER_DIGEST" />
</intent-filter>

View file

@ -55,6 +55,6 @@ class LiveTestInstrumentation @Inject constructor() : TestInstrumentation {
}
companion object {
const val TAG = "CLAUDE_TEST"
const val TAG = "CLAUDE_SNI_TEST"
}
}

View file

@ -1,9 +1,19 @@
package com.roundingmobile.sni.instrumentation
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.RemoteInput
import com.roundingmobile.sni.domain.model.CapturedNotification
import com.roundingmobile.sni.domain.repository.NotificationRepository
import dagger.hilt.android.AndroidEntryPoint
@ -114,6 +124,23 @@ class TestBroadcastReceiver : BroadcastReceiver() {
}
}
}
ACTION_SEND_NOTIFICATION -> {
sendRealNotification(context, intent)
}
ACTION_NOTIFICATION_ACTION_CLICKED -> {
val label = intent.getStringExtra("action_label") ?: "?"
val notifId = intent.getIntExtra("notif_id", -1)
val replyText = RemoteInput.getResultsFromIntent(intent)
?.getCharSequence("reply_text")?.toString()
if (replyText != null) {
Log.v(TAG, "EVENT action_clicked notif_id=$notifId label=\"$label\" reply=\"$replyText\"")
// Post a confirmation notification so the reply shows up in the timeline
postActionConfirmation(context, notifId, label, replyText)
} else {
Log.v(TAG, "EVENT action_clicked notif_id=$notifId label=\"$label\"")
postActionConfirmation(context, notifId, label, null)
}
}
ACTION_REMOVE_NOTIFICATION -> {
val id = intent.getIntExtra("notification_id", -1).toLong()
if (id > 0) {
@ -163,8 +190,178 @@ class TestBroadcastReceiver : BroadcastReceiver() {
val timestamp: Long? = null
)
/**
* Post a real Android notification via NotificationManager.
* The NotificationListenerService will capture it like any other notification.
*
* Action format: comma-separated labels. Prefix with @ for reply actions.
* "Delete,Archive" two simple buttons
* "@Reply,Delete" reply action (with RemoteInput) + delete button
* "@Reply:Type a message" reply with custom hint text
*
* Style: pass --es style "messaging" for MessagingStyle (like chat apps).
*
* Usage:
* adb shell am broadcast -a com.roundingmobile.sni.test.SEND_NOTIFICATION \
* -p com.roundingmobile.sni.dev \
* --es title "John" --es text "Hey!" \
* --es actions "@Reply,Delete,Archive" \
* --es style "messaging"
*/
private fun sendRealNotification(context: Context, intent: Intent) {
val title = intent.getStringExtra("title") ?: "Test Notification"
val text = intent.getStringExtra("text") ?: "This is a test notification"
val actionsStr = intent.getStringExtra("actions")
val style = intent.getStringExtra("style")
val notifId = intent.getIntExtra("notif_id", (System.currentTimeMillis() % 100000).toInt())
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Create channel if needed
val channelId = "sni_test_channel"
if (nm.getNotificationChannel(channelId) == null) {
val channel = NotificationChannel(
channelId, "SNI Test Notifications",
NotificationManager.IMPORTANCE_DEFAULT
)
nm.createNotificationChannel(channel)
}
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(text)
.setAutoCancel(true)
// Load image if provided (file path or "generate" for a test bitmap)
val imagePath = intent.getStringExtra("image")
val imageBitmap: Bitmap? = when {
imagePath == "generate" -> generateTestBitmap()
imagePath != null -> runCatching { BitmapFactory.decodeFile(imagePath) }.getOrNull()
else -> null
}
// Apply notification style
when (style) {
"messaging" -> {
val person = androidx.core.app.Person.Builder().setName(title).build()
val messagingStyle = NotificationCompat.MessagingStyle(person)
.addMessage(text, System.currentTimeMillis(), person)
builder.setStyle(messagingStyle)
}
"bigtext" -> {
builder.setStyle(NotificationCompat.BigTextStyle().bigText(text))
}
"inbox" -> {
val inboxStyle = NotificationCompat.InboxStyle()
text.split("|").forEach { inboxStyle.addLine(it.trim()) }
builder.setStyle(inboxStyle)
}
"bigpicture" -> {
val pic = imageBitmap ?: generateTestBitmap()
builder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(pic))
}
}
// Also set largeIcon if image provided (shows as thumbnail in collapsed state)
if (imageBitmap != null && style != "bigpicture") {
builder.setLargeIcon(imageBitmap)
}
// Add action buttons — prefix @ means reply action with RemoteInput
actionsStr?.split(",")?.map { it.trim() }?.forEachIndexed { index, raw ->
val isReply = raw.startsWith("@")
val labelAndHint = if (isReply) raw.removePrefix("@") else raw
val label = labelAndHint.substringBefore(":")
val hint = if (labelAndHint.contains(":")) labelAndHint.substringAfter(":") else "Type your reply…"
val actionIntent = Intent(context, TestBroadcastReceiver::class.java).apply {
action = ACTION_NOTIFICATION_ACTION_CLICKED
putExtra("action_label", label)
putExtra("notif_id", notifId)
}
// Reply actions need FLAG_MUTABLE so RemoteInput can write results
val flags = if (isReply) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
}
val pi = PendingIntent.getBroadcast(
context, notifId * 10 + index, actionIntent, flags
)
if (isReply) {
val remoteInput = RemoteInput.Builder("reply_text")
.setLabel(hint)
.build()
val action = NotificationCompat.Action.Builder(0, label, pi)
.addRemoteInput(remoteInput)
.build()
builder.addAction(action)
} else {
builder.addAction(0, label, pi)
}
}
nm.notify("sni_test", notifId, builder.build())
Log.v(TAG, "EVENT send_notification id=$notifId title=\"$title\" actions=\"$actionsStr\" style=$style")
}
/**
* Post a confirmation notification when a notification action is executed.
* For replies, shows the reply text. For other actions, shows what was tapped.
* This notification gets captured by our service and appears in the timeline,
* giving visible proof that the action worked.
*/
private fun postActionConfirmation(context: Context, originalNotifId: Int, label: String, replyText: String?) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channelId = "sni_test_channel"
val title = if (replyText != null) "Reply sent" else "Action: $label"
val text = if (replyText != null) {
"You replied: \"$replyText\" (via $label on notif #$originalNotifId)"
} else {
"\"$label\" tapped on notification #$originalNotifId"
}
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setAutoCancel(true)
.build()
val confirmId = originalNotifId + 50000
nm.notify("sni_test", confirmId, notification)
Log.v(TAG, "EVENT action_confirmation notif_id=$originalNotifId label=\"$label\" reply=\"$replyText\"")
}
/** Generate a colorful test bitmap that looks like a sticker/image */
private fun generateTestBitmap(): Bitmap {
val size = 200
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Background gradient
canvas.drawColor(Color.parseColor("#FF6B6B"))
// Circle
paint.color = Color.parseColor("#4ECDC4")
canvas.drawCircle(size / 2f, size / 2f, size / 3f, paint)
// Text
paint.color = Color.WHITE
paint.textSize = 40f
paint.textAlign = Paint.Align.CENTER
canvas.drawText("TEST", size / 2f, size / 2f + 14f, paint)
return bitmap
}
companion object {
private const val TAG = "CLAUDE_TEST"
private const val TAG = "CLAUDE_SNI_TEST"
private const val PREFIX = "com.roundingmobile.sni.test."
const val ACTION_DUMP_UI = "${PREFIX}DUMP_UI"
const val ACTION_DUMP_STATE = "${PREFIX}DUMP_STATE"
@ -173,8 +370,10 @@ class TestBroadcastReceiver : BroadcastReceiver() {
const val ACTION_CLEAR_DB = "${PREFIX}CLEAR_DB"
const val ACTION_INJECT_NOTIFICATION = "${PREFIX}INJECT_NOTIFICATION"
const val ACTION_INJECT_BATCH = "${PREFIX}INJECT_BATCH"
const val ACTION_SEND_NOTIFICATION = "${PREFIX}SEND_NOTIFICATION"
const val ACTION_REMOVE_NOTIFICATION = "${PREFIX}REMOVE_NOTIFICATION"
const val ACTION_TRIGGER_CLEANUP = "${PREFIX}TRIGGER_CLEANUP"
const val ACTION_TRIGGER_DIGEST = "${PREFIX}TRIGGER_DIGEST"
const val ACTION_NOTIFICATION_ACTION_CLICKED = "${PREFIX}ACTION_CLICKED"
}
}

View file

@ -77,6 +77,12 @@ object DatabaseModule {
}
}
private val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE notifications ADD COLUMN deletion_reason TEXT DEFAULT NULL")
}
}
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
@ -84,8 +90,8 @@ object DatabaseModule {
context,
AppDatabase::class.java,
"sni.db"
).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
.fallbackToDestructiveMigration()
).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
}

View file

@ -20,7 +20,7 @@ import com.roundingmobile.sni.data.local.db.entity.NotificationFtsEntity
AppFilterEntity::class,
FilterRuleEntity::class
],
version = 6,
version = 7,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {

View file

@ -108,18 +108,21 @@ interface NotificationDao {
// --- Soft delete ---
@Query("UPDATE notifications SET deleted_at = :deletedAt WHERE id = :id")
suspend fun softDeleteById(id: Long, deletedAt: Long)
@Query("UPDATE notifications SET deleted_at = :deletedAt, deletion_reason = :reason WHERE id = :id")
suspend fun softDeleteById(id: Long, deletedAt: Long, reason: String)
@Query("UPDATE notifications SET deleted_at = NULL WHERE id = :id")
@Query("UPDATE notifications SET deleted_at = NULL, deletion_reason = NULL WHERE id = :id")
suspend fun undoSoftDelete(id: Long)
@Query("UPDATE notifications SET deleted_at = :deletedAt WHERE id IN (:ids)")
suspend fun softDeleteByIds(ids: List<Long>, deletedAt: Long)
@Query("UPDATE notifications SET deleted_at = :deletedAt, deletion_reason = :reason WHERE id IN (:ids)")
suspend fun softDeleteByIds(ids: List<Long>, deletedAt: Long, reason: String)
@Query("UPDATE notifications SET deleted_at = NULL WHERE id IN (:ids)")
@Query("UPDATE notifications SET deleted_at = NULL, deletion_reason = NULL WHERE id IN (:ids)")
suspend fun undoSoftDeleteByIds(ids: List<Long>)
@Query("UPDATE notifications SET deletion_reason = :reason WHERE id = :id")
suspend fun setDeletionReason(id: Long, reason: String)
@Query("SELECT COUNT(*) FROM notifications WHERE deleted_at IS NOT NULL")
fun getDeletedCountFlow(): Flow<Int>

View file

@ -39,5 +39,6 @@ data class NotificationEntity(
@ColumnInfo(name = "actions_json") val actionsJson: String? = null,
@ColumnInfo(name = "icon_uri") val iconUri: String? = null,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "deleted_at") val deletedAt: Long? = null
@ColumnInfo(name = "deleted_at") val deletedAt: Long? = null,
@ColumnInfo(name = "deletion_reason") val deletionReason: String? = null
)

View file

@ -26,7 +26,8 @@ fun NotificationEntity.toDomain(): CapturedNotification = CapturedNotification(
actionsJson = actionsJson,
iconUri = iconUri,
createdAt = createdAt,
deletedAt = deletedAt
deletedAt = deletedAt,
deletionReason = deletionReason
)
fun CapturedNotification.toEntity(): NotificationEntity = NotificationEntity(
@ -52,5 +53,6 @@ fun CapturedNotification.toEntity(): NotificationEntity = NotificationEntity(
actionsJson = actionsJson,
iconUri = iconUri,
createdAt = createdAt,
deletedAt = deletedAt
deletedAt = deletedAt,
deletionReason = deletionReason
)

View file

@ -99,18 +99,21 @@ class NotificationRepositoryImpl @Inject constructor(
override suspend fun getCount(): Int = notificationDao.getCount()
override suspend fun softDeleteById(id: Long) =
notificationDao.softDeleteById(id, System.currentTimeMillis())
override suspend fun softDeleteById(id: Long, reason: String) =
notificationDao.softDeleteById(id, System.currentTimeMillis(), reason)
override suspend fun undoSoftDelete(id: Long) =
notificationDao.undoSoftDelete(id)
override suspend fun softDeleteByIds(ids: List<Long>) =
notificationDao.softDeleteByIds(ids, System.currentTimeMillis())
override suspend fun softDeleteByIds(ids: List<Long>, reason: String) =
notificationDao.softDeleteByIds(ids, System.currentTimeMillis(), reason)
override suspend fun undoSoftDeleteByIds(ids: List<Long>) =
notificationDao.undoSoftDeleteByIds(ids)
override suspend fun setDeletionReason(id: Long, reason: String) =
notificationDao.setDeletionReason(id, reason)
override fun getDeletedCountFlow(): Flow<Int> = notificationDao.getDeletedCountFlow()
override suspend fun countOlderThan(before: Long): Int =

View file

@ -23,7 +23,8 @@ data class CapturedNotification(
val actionsJson: String?,
val iconUri: String?,
val createdAt: Long,
val deletedAt: Long? = null
val deletedAt: Long? = null,
val deletionReason: String? = null
) {
val isPossiblyDeleted: Boolean
get() = isRemoved && removalDelayMs != null && removalDelayMs < DELETED_THRESHOLD_MS

View file

@ -43,7 +43,8 @@ enum class FilterAction {
BLOCK,
SAVE_AS_READ,
COOLDOWN,
ALERT;
ALERT,
EXECUTE_ACTION;
companion object {
fun fromLegacy(name: String): FilterAction = when (name) {

View file

@ -24,10 +24,11 @@ interface NotificationRepository {
suspend fun findRecentByPackageAndTitle(packageName: String, title: String, sinceMs: Long): CapturedNotification?
suspend fun update(notification: CapturedNotification)
suspend fun getCount(): Int
suspend fun softDeleteById(id: Long)
suspend fun softDeleteById(id: Long, reason: String = "USER")
suspend fun undoSoftDelete(id: Long)
suspend fun softDeleteByIds(ids: List<Long>)
suspend fun softDeleteByIds(ids: List<Long>, reason: String = "USER_BULK")
suspend fun undoSoftDeleteByIds(ids: List<Long>)
suspend fun setDeletionReason(id: Long, reason: String)
fun getDeletedCountFlow(): Flow<Int>
suspend fun countOlderThan(before: Long): Int
suspend fun purgeDeletedOlderThan(before: Long): Int

View file

@ -20,7 +20,7 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.MarkEmailRead
import androidx.compose.material.icons.filled.MarkEmailUnread
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@ -200,7 +200,7 @@ fun SwipeableNotificationItem(
)
DropdownMenuItem(
text = { Text(stringResource(R.string.action_open_in_app)) },
leadingIcon = { Icon(Icons.Default.OpenInNew, null) },
leadingIcon = { Icon(Icons.AutoMirrored.Filled.OpenInNew, null) },
onClick = {
showMenu = false
NotificationActions.openSourceApp(context, notification.packageName)

View file

@ -28,16 +28,29 @@ import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.MarkEmailRead
import androidx.compose.material.icons.filled.MarkEmailUnread
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.ThumbUp
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
@ -58,6 +71,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@ -247,10 +261,12 @@ private fun DetailContent(
// Media image (sticker, picture)
if (!notification.iconUri.isNullOrBlank()) {
val imageBitmap = remember(notification.iconUri) {
val file = File(notification.iconUri)
if (file.exists()) {
BitmapFactory.decodeFile(file.absolutePath)?.asImageBitmap()
} else null
runCatching {
val file = File(notification.iconUri)
if (file.exists()) {
BitmapFactory.decodeFile(file.absolutePath)?.asImageBitmap()
} else null
}.getOrNull()
}
if (imageBitmap != null) {
Spacer(modifier = Modifier.height(12.dp))
@ -266,6 +282,13 @@ private fun DetailContent(
}
}
// Auto-action status banner
val autoActionReason = notification.deletionReason
if (autoActionReason != null && autoActionReason.startsWith("AUTO_ACTION")) {
Spacer(modifier = Modifier.height(12.dp))
AutoActionBanner(autoActionReason)
}
// Action buttons
Spacer(modifier = Modifier.height(20.dp))
Row(
@ -319,7 +342,7 @@ private fun DetailContent(
.weight(1f)
.testTrack(instrumentation, "btn_open_app", "Open")
) {
Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
Text(stringResource(R.string.action_open_app))
}
}
@ -370,6 +393,11 @@ private fun DetailContent(
modifier = Modifier.padding(top = 4.dp)
) {
liveActions.forEachIndexed { index, actionInfo ->
val icon = if (actionInfo.hasRemoteInput) {
Icons.AutoMirrored.Filled.Reply
} else {
actionLabelIcon(actionInfo.label)
}
AssistChip(
onClick = {
if (actionInfo.hasRemoteInput) {
@ -384,10 +412,10 @@ private fun DetailContent(
}
},
label = { Text(actionInfo.label) },
leadingIcon = if (actionInfo.hasRemoteInput) {
leadingIcon = if (icon != null) {
{
Icon(
Icons.AutoMirrored.Filled.Reply,
icon,
contentDescription = null,
modifier = Modifier.padding(end = 2.dp)
)
@ -416,6 +444,78 @@ private fun DetailContent(
}
}
/** Map common notification action labels to Material icons */
private fun actionLabelIcon(label: String): ImageVector? {
val lower = label.lowercase()
return when {
lower.contains("delete") || lower.contains("remove") || lower.contains("trash") -> Icons.Default.Delete
lower.contains("archive") -> Icons.Default.Archive
lower.contains("mark") && lower.contains("read") -> Icons.Default.DoneAll
lower.contains("like") || lower.contains("heart") -> Icons.Default.Favorite
lower.contains("thumbs") || lower.contains("thumb") -> Icons.Default.ThumbUp
lower.contains("call") || lower.contains("dial") -> Icons.Default.Call
lower.contains("share") -> Icons.Default.Share
lower.contains("open") || lower.contains("view") -> Icons.Default.Visibility
lower.contains("block") || lower.contains("spam") -> Icons.Default.Block
lower.contains("dismiss") || lower.contains("close") || lower.contains("cancel") -> Icons.Default.Close
lower.contains("accept") || lower.contains("confirm") || lower.contains("ok") -> Icons.Default.Check
lower.contains("mute") || lower.contains("snooze") -> Icons.Default.NotificationsOff
else -> null
}
}
@Composable
private fun AutoActionBanner(reason: String) {
val isFailed = reason.startsWith("AUTO_ACTION_FAILED")
val containerColor = if (isFailed)
MaterialTheme.colorScheme.errorContainer
else
MaterialTheme.colorScheme.tertiaryContainer
val contentColor = if (isFailed)
MaterialTheme.colorScheme.onErrorContainer
else
MaterialTheme.colorScheme.onTertiaryContainer
// Parse the reason string: "AUTO_ACTION_FAILED:Delete button missing (rule:42:Gmail spam)"
// or "AUTO_ACTION_OK:Delete (rule:42:Gmail spam)"
val detail = reason.substringAfter(":")
val displayText = if (isFailed) {
stringResource(R.string.detail_auto_action_failed, detail.substringBefore(" ("))
} else {
val actionLabel = detail.substringBefore(" (")
val ruleName = detail.substringAfter(":", "").substringAfter(":")
.removeSuffix(")")
stringResource(R.string.detail_auto_action_success, actionLabel, ruleName)
}
Card(
colors = CardDefaults.cardColors(
containerColor = containerColor,
contentColor = contentColor
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (isFailed) {
Icon(
Icons.Default.ErrorOutline,
contentDescription = null,
tint = contentColor
)
}
Text(
text = displayText,
style = MaterialTheme.typography.bodySmall,
color = contentColor
)
}
}
}
@Composable
private fun InfoForNerdsSection(notification: CapturedNotification) {
var expanded by remember { mutableStateOf(false) }
@ -436,6 +536,14 @@ private fun InfoForNerdsSection(notification: CapturedNotification) {
NerdRow(stringResource(R.string.detail_update_of_label), "#${notification.previousVersionId ?: 0}")
}
// Deletion reason
if (!notification.deletionReason.isNullOrBlank()) {
NerdRow(
stringResource(R.string.detail_deletion_reason_label),
notification.deletionReason
)
}
// Removal info
if (notification.isRemoved) {
NerdRow(stringResource(R.string.detail_removed_label), stringResource(R.string.detail_yes))
@ -503,6 +611,13 @@ private fun ReplyDialog(
onDismiss: () -> Unit
) {
var replyText by remember { mutableStateOf("") }
val focusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
androidx.compose.runtime.LaunchedEffect(Unit) {
// Small delay to let the dialog animate in before requesting focus
kotlinx.coroutines.delay(100)
focusRequester.requestFocus()
}
AlertDialog(
onDismissRequest = onDismiss,
@ -512,8 +627,10 @@ private fun ReplyDialog(
value = replyText,
onValueChange = { replyText = it },
placeholder = { Text(hint) },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
minLines = 3,
maxLines = 5
)
},

View file

@ -17,7 +17,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Backspace
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -175,7 +175,7 @@ fun LockScreen(
enabled = !isCooldown
) {
Icon(
Icons.Default.Backspace,
Icons.AutoMirrored.Filled.Backspace,
contentDescription = stringResource(R.string.cd_delete),
modifier = Modifier.size(28.dp)
)

View file

@ -21,6 +21,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
@ -86,6 +87,12 @@ fun FilterRuleEditorSheet(
var cooldownMinutes by remember {
mutableStateOf(parseCooldownMinutes(initialRule.actionParams))
}
var executeActionLabel by remember {
mutableStateOf(parseExecuteActionLabel(initialRule.actionParams))
}
var executeAlsoDelete by remember {
mutableStateOf(parseExecuteAlsoDelete(initialRule.actionParams))
}
// Editable condition rows
val conditions = remember {
@ -219,7 +226,8 @@ fun FilterRuleEditorSheet(
FilterAction.BLOCK to stringResource(R.string.filter_action_block),
FilterAction.SAVE_AS_READ to stringResource(R.string.filter_action_save_as_read),
FilterAction.COOLDOWN to stringResource(R.string.filter_action_cooldown),
FilterAction.ALERT to stringResource(R.string.filter_action_alert)
FilterAction.ALERT to stringResource(R.string.filter_action_alert),
FilterAction.EXECUTE_ACTION to stringResource(R.string.filter_action_execute)
),
onSelect = { action = it }
)
@ -246,6 +254,32 @@ fun FilterRuleEditorSheet(
}
}
// Execute action params
AnimatedVisibility(visible = action == FilterAction.EXECUTE_ACTION) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
SentenceLabel(stringResource(R.string.filter_sentence_the_button_labeled))
OutlinedTextField(
value = executeActionLabel,
onValueChange = { executeActionLabel = it },
placeholder = { Text(stringResource(R.string.filter_execute_action_label_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = executeAlsoDelete,
onCheckedChange = { executeAlsoDelete = it }
)
Text(
stringResource(R.string.filter_execute_also_delete),
style = MaterialTheme.typography.bodySmall
)
}
}
}
// Regex helper for any condition using regex
val hasRegex = conditions.any { it.matchType == MatchType.REGEX }
AnimatedVisibility(visible = hasRegex) {
@ -308,9 +342,11 @@ fun FilterRuleEditorSheet(
val finalConditions = conditions
.filter { it.pattern.isNotBlank() }
.map { FilterCondition.of(it.matchField, it.matchType, it.pattern) }
val params = if (action == FilterAction.COOLDOWN) {
"""{"duration_ms":${cooldownMinutes.toLong() * 60_000}}"""
} else null
val params = when (action) {
FilterAction.COOLDOWN -> """{"duration_ms":${cooldownMinutes.toLong() * 60_000}}"""
FilterAction.EXECUTE_ACTION -> """{"action_label":"${executeActionLabel.replace("\"", "\\\"")}","also_delete":$executeAlsoDelete}"""
else -> null
}
val res = context.resources
onSave(
initialRule.copy(
@ -469,3 +505,19 @@ private fun parseCooldownMinutes(params: String?): Int {
(ms / 60_000).toInt().coerceIn(1, 60)
} catch (_: Exception) { 2 }
}
private fun parseExecuteActionLabel(params: String?): String {
if (params == null) return ""
return try {
val obj = kotlinx.serialization.json.Json.parseToJsonElement(params).asJsonObjectOrNull()
obj?.get("action_label")?.asJsonPrimitiveOrNull()?.content ?: ""
} catch (_: Exception) { "" }
}
private fun parseExecuteAlsoDelete(params: String?): Boolean {
if (params == null) return false
return try {
val obj = kotlinx.serialization.json.Json.parseToJsonElement(params).asJsonObjectOrNull()
obj?.get("also_delete")?.asJsonPrimitiveOrNull()?.content?.toBooleanStrictOrNull() ?: false
} catch (_: Exception) { false }
}

View file

@ -25,6 +25,7 @@ internal fun buildCompoundSummary(
FilterAction.SAVE_AS_READ -> resources.getString(R.string.filter_action_save_as_read)
FilterAction.COOLDOWN -> resources.getString(R.string.filter_summary_cooldown_for, cooldownMinutes)
FilterAction.ALERT -> resources.getString(R.string.filter_action_alert)
FilterAction.EXECUTE_ACTION -> resources.getString(R.string.filter_action_execute)
}
val validConditions = conditions.filter { it.pattern.isNotBlank() }
if (validConditions.isEmpty()) return resources.getString(R.string.filter_summary_when_sends, appLabel, actionLabel)
@ -82,6 +83,7 @@ internal fun autoGenerateName(
FilterAction.SAVE_AND_DISMISS -> resources.getString(R.string.filter_auto_name_dismiss)
FilterAction.COOLDOWN -> resources.getString(R.string.filter_auto_name_cooldown)
FilterAction.ALERT -> resources.getString(R.string.filter_auto_name_alert)
FilterAction.EXECUTE_ACTION -> resources.getString(R.string.filter_auto_name_execute)
}
val pattern = conditions.firstOrNull()?.pattern ?: ""
val target = when {

View file

@ -52,7 +52,8 @@ class NotificationCaptureService : NotificationListenerService() {
private val cooldowns = ConcurrentHashMap<String, Long>()
override fun onNotificationPosted(sbn: StatusBarNotification) {
if (sbn.packageName == packageName) return
// Skip own notifications, unless tagged as test notifications (dev flavor testing)
if (sbn.packageName == packageName && sbn.tag != "sni_test") return
// System/spam filter
if (filter.shouldIgnore(sbn)) return
@ -137,6 +138,10 @@ class NotificationCaptureService : NotificationListenerService() {
Log.d(TAG, "Alert rule matched: pkg=${sbn.packageName} title=${parsed.title}")
}
}
FilterAction.EXECUTE_ACTION -> {
handleExecuteAction(sbn, parsed, appName, ruleResult)
return@launch
}
}
}
@ -206,6 +211,100 @@ class NotificationCaptureService : NotificationListenerService() {
)
}
private suspend fun handleExecuteAction(
sbn: StatusBarNotification,
parsed: com.roundingmobile.sni.domain.model.CapturedNotification,
appName: String,
ruleResult: com.roundingmobile.sni.domain.usecase.RuleResult
) {
val params = ruleResult.actionParams
val targetLabel = parseExecuteActionLabel(params)
val alsoDelete = parseExecuteAlsoDelete(params)
val ruleName = ruleResult.rule.name
if (targetLabel.isBlank()) {
Log.w(TAG, "EXECUTE_ACTION rule has no action_label configured")
saveNotification(sbn, parsed, appName, isRead = false)
return
}
// Find the matching action button on the live notification
val actions = sbn.notification.actions
val actionIndex = actions?.indexOfFirst {
it.title.toString().equals(targetLabel, ignoreCase = true)
} ?: -1
if (actionIndex >= 0 && actions != null) {
// Found the button — execute it
try {
actions[actionIndex].actionIntent.send()
Log.d(TAG, "Auto-action executed: \"$targetLabel\" on ${sbn.packageName} (rule: $ruleName)")
if (alsoDelete) {
// Save then immediately soft-delete with reason
val insertedId = repository.insert(parsed)
repository.softDeleteById(
insertedId,
"AUTO_ACTION:$targetLabel (rule:${ruleResult.rule.id}:$ruleName)"
)
} else {
// Save with a note that action was auto-executed
val withNote = parsed.copy(
deletionReason = "AUTO_ACTION_OK:$targetLabel (rule:${ruleResult.rule.id}:$ruleName)"
)
repository.insert(withNote)
}
instrumentation.reportEvent(
"auto_action_executed",
mapOf(
"package" to sbn.packageName,
"action" to targetLabel,
"rule" to ruleName
)
)
} catch (e: Exception) {
Log.w(TAG, "Auto-action send failed for \"$targetLabel\": ${e.message}")
val withNote = parsed.copy(
deletionReason = "AUTO_ACTION_FAILED:$targetLabel send error (rule:${ruleResult.rule.id}:$ruleName)"
)
repository.insert(withNote)
}
} else {
// Button not found — save notification with failure note, do NOT delete
Log.d(TAG, "Auto-action button \"$targetLabel\" not found on ${sbn.packageName} (rule: $ruleName)")
val withNote = parsed.copy(
deletionReason = "AUTO_ACTION_FAILED:$targetLabel button missing (rule:${ruleResult.rule.id}:$ruleName)"
)
repository.insert(withNote)
instrumentation.reportEvent(
"auto_action_missing",
mapOf(
"package" to sbn.packageName,
"action" to targetLabel,
"rule" to ruleName,
"available" to (actions?.joinToString { it.title } ?: "none")
)
)
}
}
private fun parseExecuteActionLabel(params: String?): String {
if (params == null) return ""
return try {
val obj = Json.parseToJsonElement(params).jsonObject
obj["action_label"]?.jsonPrimitive?.content ?: ""
} catch (_: Exception) { "" }
}
private fun parseExecuteAlsoDelete(params: String?): Boolean {
if (params == null) return false
return try {
val obj = Json.parseToJsonElement(params).jsonObject
obj["also_delete"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false
} catch (_: Exception) { false }
}
private fun dismissNotification(sbn: StatusBarNotification) {
try {
cancelNotification(sbn.key)

View file

@ -56,4 +56,19 @@
<string name="sort_newest">Més recents</string>
<string name="sort_oldest">Més antigues</string>
<string name="sort_by_app">Per app</string>
<!-- Auto-action & deletion reason -->
<string name="filter_action_execute">prémer botó d\'acció</string>
<string name="filter_auto_name_execute">Acció auto</string>
<string name="filter_sentence_the_button_labeled">el botó anomenat</string>
<string name="filter_execute_action_label_hint">ex. Eliminar, Arxivar, Marcar com a llegit</string>
<string name="filter_execute_also_delete">També eliminar de la nostra llista si l\'acció té èxit</string>
<string name="detail_deletion_reason_label">Motiu d\'eliminació</string>
<string name="detail_deletion_reason_user">Eliminat per tu</string>
<string name="detail_deletion_reason_user_bulk">Eliminació massiva per tu</string>
<string name="detail_deletion_reason_retention">Caducat (neteja de retenció)</string>
<string name="detail_deletion_reason_filter">Eliminat per regla de filtre: %1$s</string>
<string name="detail_auto_action_success">Acció automàtica executada: %1$s (regla: %2$s)</string>
<string name="detail_auto_action_failed">Acció automàtica fallida: botó \"%1$s\" no trobat</string>
<string name="detail_auto_action_note_label">Estat de l\'acció automàtica</string>
</resources>

View file

@ -56,4 +56,19 @@
<string name="sort_newest">Neueste zuerst</string>
<string name="sort_oldest">Älteste zuerst</string>
<string name="sort_by_app">Nach App</string>
<!-- Auto-action & deletion reason -->
<string name="filter_action_execute">Aktionsschaltfläche antippen</string>
<string name="filter_auto_name_execute">Auto-Aktion</string>
<string name="filter_sentence_the_button_labeled">die Schaltfläche mit dem Namen</string>
<string name="filter_execute_action_label_hint">z.B. Löschen, Archivieren, Als gelesen markieren</string>
<string name="filter_execute_also_delete">Auch aus unserer Liste entfernen, wenn die Aktion erfolgreich ist</string>
<string name="detail_deletion_reason_label">Löschgrund</string>
<string name="detail_deletion_reason_user">Von dir gelöscht</string>
<string name="detail_deletion_reason_user_bulk">Massenlöschung durch dich</string>
<string name="detail_deletion_reason_retention">Abgelaufen (Aufbewahrungsbereinigung)</string>
<string name="detail_deletion_reason_filter">Gelöscht durch Filterregel: %1$s</string>
<string name="detail_auto_action_success">Automatische Aktion ausgeführt: %1$s (Regel: %2$s)</string>
<string name="detail_auto_action_failed">Automatische Aktion fehlgeschlagen: Schaltfläche \"%1$s\" nicht gefunden</string>
<string name="detail_auto_action_note_label">Status der automatischen Aktion</string>
</resources>

View file

@ -56,4 +56,19 @@
<string name="sort_newest">Más recientes</string>
<string name="sort_oldest">Más antiguas</string>
<string name="sort_by_app">Por app</string>
<!-- Auto-action & deletion reason -->
<string name="filter_action_execute">ejecutar botón de acción</string>
<string name="filter_auto_name_execute">Auto-acción</string>
<string name="filter_sentence_the_button_labeled">el botón llamado</string>
<string name="filter_execute_action_label_hint">ej. Eliminar, Archivar, Marcar como leído</string>
<string name="filter_execute_also_delete">También eliminar de nuestra lista si la acción tiene éxito</string>
<string name="detail_deletion_reason_label">Motivo de eliminación</string>
<string name="detail_deletion_reason_user">Eliminado por ti</string>
<string name="detail_deletion_reason_user_bulk">Eliminación masiva por ti</string>
<string name="detail_deletion_reason_retention">Expirado (limpieza de retención)</string>
<string name="detail_deletion_reason_filter">Eliminado por regla de filtro: %1$s</string>
<string name="detail_auto_action_success">Acción automática ejecutada: %1$s (regla: %2$s)</string>
<string name="detail_auto_action_failed">Acción automática fallida: botón \"%1$s\" no encontrado</string>
<string name="detail_auto_action_note_label">Estado de acción automática</string>
</resources>

View file

@ -56,4 +56,19 @@
<string name="sort_newest">Plus récentes</string>
<string name="sort_oldest">Plus anciennes</string>
<string name="sort_by_app">Par application</string>
<!-- Auto-action & deletion reason -->
<string name="filter_action_execute">appuyer sur le bouton d\'action</string>
<string name="filter_auto_name_execute">Action auto</string>
<string name="filter_sentence_the_button_labeled">le bouton intitulé</string>
<string name="filter_execute_action_label_hint">ex. Supprimer, Archiver, Marquer comme lu</string>
<string name="filter_execute_also_delete">Supprimer aussi de notre liste si l\'action réussit</string>
<string name="detail_deletion_reason_label">Raison de la suppression</string>
<string name="detail_deletion_reason_user">Supprimé par vous</string>
<string name="detail_deletion_reason_user_bulk">Suppression groupée par vous</string>
<string name="detail_deletion_reason_retention">Expiré (nettoyage de rétention)</string>
<string name="detail_deletion_reason_filter">Supprimé par la règle de filtre : %1$s</string>
<string name="detail_auto_action_success">Action automatique exécutée : %1$s (règle : %2$s)</string>
<string name="detail_auto_action_failed">Action automatique échouée : bouton \"%1$s\" introuvable</string>
<string name="detail_auto_action_note_label">Statut de l\'action automatique</string>
</resources>

View file

@ -56,4 +56,19 @@
<string name="sort_newest">Nyast först</string>
<string name="sort_oldest">Äldst först</string>
<string name="sort_by_app">Efter app</string>
<!-- Auto-action & deletion reason -->
<string name="filter_action_execute">tryck på åtgärdsknapp</string>
<string name="filter_auto_name_execute">Autoåtgärd</string>
<string name="filter_sentence_the_button_labeled">knappen med namnet</string>
<string name="filter_execute_action_label_hint">t.ex. Radera, Arkivera, Markera som läst</string>
<string name="filter_execute_also_delete">Ta även bort från vår lista om åtgärden lyckas</string>
<string name="detail_deletion_reason_label">Raderingsorsak</string>
<string name="detail_deletion_reason_user">Raderad av dig</string>
<string name="detail_deletion_reason_user_bulk">Massraderad av dig</string>
<string name="detail_deletion_reason_retention">Utgången (lagringsrensning)</string>
<string name="detail_deletion_reason_filter">Raderad av filterregel: %1$s</string>
<string name="detail_auto_action_success">Automatisk åtgärd utförd: %1$s (regel: %2$s)</string>
<string name="detail_auto_action_failed">Automatisk åtgärd misslyckades: knappen \"%1$s\" hittades inte</string>
<string name="detail_auto_action_note_label">Status för automatisk åtgärd</string>
</resources>

View file

@ -56,6 +56,14 @@
<string name="detail_notification_id_label">Notification ID</string>
<string name="detail_update_of_label">Update of</string>
<string name="detail_received">Received %1$s</string>
<string name="detail_deletion_reason_label">Deletion reason</string>
<string name="detail_deletion_reason_user">Deleted by you</string>
<string name="detail_deletion_reason_user_bulk">Bulk deleted by you</string>
<string name="detail_deletion_reason_retention">Expired (retention cleanup)</string>
<string name="detail_deletion_reason_filter">Deleted by filter rule: %1$s</string>
<string name="detail_auto_action_success">Auto-action executed: %1$s (rule: %2$s)</string>
<string name="detail_auto_action_failed">Auto-action failed: \"%1$s\" button not found</string>
<string name="detail_auto_action_note_label">Auto-action status</string>
<!-- Actions -->
<string name="action_copy">Copy</string>
@ -192,6 +200,7 @@
<string name="filter_action_save_as_read">save as read</string>
<string name="filter_action_cooldown">cooldown</string>
<string name="filter_action_alert">alert me</string>
<string name="filter_action_execute">tap action button</string>
<!-- Filter rule: cooldown durations -->
<string name="filter_cooldown_1m">1 minute</string>
@ -225,6 +234,7 @@
<string name="filter_auto_name_dismiss">Dismiss</string>
<string name="filter_auto_name_cooldown">Cooldown</string>
<string name="filter_auto_name_alert">Alert</string>
<string name="filter_auto_name_execute">Auto-tap</string>
<!-- Export errors -->
<string name="export_error_create_file">Failed to create file</string>
@ -263,6 +273,9 @@
<string name="filter_sentence_then">then</string>
<string name="filter_sentence_for">for</string>
<string name="filter_sentence_enter_text">enter text to match…</string>
<string name="filter_sentence_the_button_labeled">the button labeled</string>
<string name="filter_execute_action_label_hint">e.g. Delete, Archive, Mark as read</string>
<string name="filter_execute_also_delete">Also remove from our list if action succeeds</string>
<!-- Filter rule app picker -->
<string name="filter_app_picker_title">Select app</string>

View file

@ -475,7 +475,7 @@ end_time INTEGER
- **EncryptedSharedPreferences** for PIN hash, biometric flags, any sensitive settings
- **Room database is NOT encrypted by default.** For v1, rely on Android's file-based encryption (device lock). Consider SQLCipher for v2 if targeting high-security users.
- **No PII in logs.** Notification content never appears in standard logcat. Only the CLAUDE_TEST tag in testing flavor emits content, and that flavor is never distributed.
- **No PII in logs.** Notification content never appears in standard logcat. Only the CLAUDE_SNI_TEST tag in testing flavor emits content, and that flavor is never distributed.
- **No PII in Crashlytics.** Log screen names, feature events, error types — never notification text.
- **No network permissions in pro flavor.** Only free (ads) and all flavors (Crashlytics/Analytics) need network. Crashlytics/Analytics can be disabled in settings.
- **ProGuard/R8** enabled on release builds. Code obfuscation + shrinking.
@ -567,42 +567,42 @@ end_time INTEGER
## 10. Test Instrumentation (testing flavor)
### 10.1 Logcat Protocol — tag: CLAUDE_TEST
### 10.1 Logcat Protocol — tag: CLAUDE_SNI_TEST
**VIEW** — UI element positions (emitted on DUMP_UI or recomposition):
```
CLAUDE_TEST: VIEW id=timeline_list type=LazyColumn bounds=0,160,1080,2340 visible=true
CLAUDE_TEST: VIEW id=notif_item_0 type=Row bounds=0,160,1080,320 visible=true text="WhatsApp: John: See you later"
CLAUDE_TEST: VIEW id=btn_filter type=IconButton bounds=900,80,1060,156 visible=true text="Filter"
CLAUDE_TEST: VIEW id=btn_search type=IconButton bounds=740,80,900,156 visible=true text="Search"
CLAUDE_TEST: VIEW id=search_field type=TextField bounds=20,80,700,156 visible=true text="" focused=false
CLAUDE_TEST: VIEW id=bottom_nav_timeline type=NavItem bounds=0,2340,270,2400 selected=true
CLAUDE_TEST: VIEW id=ad_banner type=AdView bounds=0,2260,1080,2340 visible=false # not in testing
CLAUDE_SNI_TEST: VIEW id=timeline_list type=LazyColumn bounds=0,160,1080,2340 visible=true
CLAUDE_SNI_TEST: VIEW id=notif_item_0 type=Row bounds=0,160,1080,320 visible=true text="WhatsApp: John: See you later"
CLAUDE_SNI_TEST: VIEW id=btn_filter type=IconButton bounds=900,80,1060,156 visible=true text="Filter"
CLAUDE_SNI_TEST: VIEW id=btn_search type=IconButton bounds=740,80,900,156 visible=true text="Search"
CLAUDE_SNI_TEST: VIEW id=search_field type=TextField bounds=20,80,700,156 visible=true text="" focused=false
CLAUDE_SNI_TEST: VIEW id=bottom_nav_timeline type=NavItem bounds=0,2340,270,2400 selected=true
CLAUDE_SNI_TEST: VIEW id=ad_banner type=AdView bounds=0,2260,1080,2340 visible=false # not in testing
```
**STATE** — app state snapshot:
```
CLAUDE_TEST: STATE screen=Timeline notification_count=47 filter=all sort=newest_first db_total=1523
CLAUDE_TEST: STATE screen=Detail notification_id=123 package=com.whatsapp is_bookmarked=false
CLAUDE_TEST: STATE screen=Settings lock_enabled=true retention=7d hidden_apps=3 analytics=on
CLAUDE_SNI_TEST: STATE screen=Timeline notification_count=47 filter=all sort=newest_first db_total=1523
CLAUDE_SNI_TEST: STATE screen=Detail notification_id=123 package=com.whatsapp is_bookmarked=false
CLAUDE_SNI_TEST: STATE screen=Settings lock_enabled=true retention=7d hidden_apps=3 analytics=on
```
**EVENT** — actions:
```
CLAUDE_TEST: EVENT navigation from=Timeline to=Detail
CLAUDE_TEST: EVENT notification_captured package=com.whatsapp title="John"
CLAUDE_TEST: EVENT notification_removed id=456 delay_ms=3200
CLAUDE_TEST: EVENT search query="amazon" results=12
CLAUDE_TEST: EVENT export format=csv count=47
CLAUDE_TEST: EVENT keyword_alert keyword="bank" notification_id=789
CLAUDE_TEST: EVENT cleanup removed=34 remaining=1489
CLAUDE_SNI_TEST: EVENT navigation from=Timeline to=Detail
CLAUDE_SNI_TEST: EVENT notification_captured package=com.whatsapp title="John"
CLAUDE_SNI_TEST: EVENT notification_removed id=456 delay_ms=3200
CLAUDE_SNI_TEST: EVENT search query="amazon" results=12
CLAUDE_SNI_TEST: EVENT export format=csv count=47
CLAUDE_SNI_TEST: EVENT keyword_alert keyword="bank" notification_id=789
CLAUDE_SNI_TEST: EVENT cleanup removed=34 remaining=1489
```
**ERROR:**
```
CLAUDE_TEST: ERROR type=db_error message="Failed to insert"
CLAUDE_TEST: ERROR type=permission_missing permission=NOTIFICATION_LISTENER
CLAUDE_TEST: ERROR type=crash message="NullPointerException in DetailViewModel"
CLAUDE_SNI_TEST: ERROR type=db_error message="Failed to insert"
CLAUDE_SNI_TEST: ERROR type=permission_missing permission=NOTIFICATION_LISTENER
CLAUDE_SNI_TEST: ERROR type=crash message="NullPointerException in DetailViewModel"
```
### 10.2 Broadcast Intents