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:
parent
327e846478
commit
a0fc459e7a
27 changed files with 1056 additions and 70 deletions
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -55,6 +55,6 @@ class LiveTestInstrumentation @Inject constructor() : TestInstrumentation {
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "CLAUDE_TEST"
|
||||
const val TAG = "CLAUDE_SNI_TEST"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue