v1.0.0-beta07: Detail navigation, action buttons DB, filter UX, read/unread styling
Published to Google Play Store. - Detail screen: prev/next bottom nav bar, delete navigates to next item with undo snackbar - Delete auto-fires app's Delete action button if available (like Gmail delete) - New app_actions table: captures notification action buttons per app (DB migration 7→8) - Filter rule editor: shows known action buttons for selected app (excluding reply actions) - Filter rule editor: dynamic placeholder text based on match field, auto-fill from source notification - Filter rule editor: "Create filter" button added to detail screen - Filter UX: renamed "tap" → "hit" action button, clearer dropdown labels throughout - Filter UX: long text auto-shortened for patterns, URL-safe sentence detection - Filter UX: frequency limiter now supports up to 24 hours - Bookmark icon: orange badge style, visible on both items and collapsed group headers - Read/unread: Gmail-style — bold text + dot + tinted background for unread, normal for read - Pro mode defaults to true on debug builds - Rule name auto-generates on save (app name + action + pattern) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a0fc459e7a
commit
651928f246
57 changed files with 2832 additions and 201 deletions
|
|
@ -34,8 +34,8 @@ android {
|
|||
applicationId = "com.roundingmobile.sni"
|
||||
minSdk = 27
|
||||
targetSdk = 35
|
||||
versionCode = 5
|
||||
versionName = "1.0.0-beta05"
|
||||
versionCode = 7
|
||||
versionName = "1.0.0-beta07"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
|
|
|
|||
14
app/proguard-rules.pro
vendored
14
app/proguard-rules.pro
vendored
|
|
@ -1,10 +1,11 @@
|
|||
# === Maximum obfuscation ===
|
||||
# Strip source file names and line numbers
|
||||
-renamesourcefileattribute ""
|
||||
-dontnote **
|
||||
# === Obfuscation ===
|
||||
-repackageclasses ""
|
||||
-allowaccessmodification
|
||||
-overloadaggressively
|
||||
-dontnote **
|
||||
|
||||
# Strip source file names and line numbers
|
||||
-renamesourcefileattribute ""
|
||||
|
||||
# Google Tink (used by EncryptedSharedPreferences)
|
||||
-dontwarn com.google.errorprone.annotations.CanIgnoreReturnValue
|
||||
|
|
@ -12,7 +13,7 @@
|
|||
-dontwarn com.google.errorprone.annotations.Immutable
|
||||
-dontwarn com.google.errorprone.annotations.RestrictedApi
|
||||
|
||||
# Firebase Crashlytics — keep exceptions readable but nothing else
|
||||
# Firebase Crashlytics — keep exceptions readable
|
||||
-keep public class * extends java.lang.Exception
|
||||
|
||||
# Room
|
||||
|
|
@ -23,6 +24,9 @@
|
|||
-keep class dagger.hilt.** { *; }
|
||||
-keep class * extends dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
||||
# WorkManager + Hilt Workers
|
||||
-keep class * extends androidx.work.ListenableWorker
|
||||
|
||||
# Kotlin Serialization
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
|
|
|
|||
|
|
@ -0,0 +1,460 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 8,
|
||||
"identityHash": "3b892e89383311661d821de37c193296",
|
||||
"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`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "app_actions",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `label` TEXT NOT NULL, `has_remote_input` INTEGER NOT NULL, `last_seen_at` INTEGER NOT NULL, `seen_count` INTEGER NOT NULL, PRIMARY KEY(`package_name`, `label`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "label",
|
||||
"columnName": "label",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasRemoteInput",
|
||||
"columnName": "has_remote_input",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSeenAt",
|
||||
"columnName": "last_seen_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "seenCount",
|
||||
"columnName": "seen_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"package_name",
|
||||
"label"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_app_actions_package_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"package_name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_actions_package_name` ON `${TABLE_NAME}` (`package_name`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"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, '3b892e89383311661d821de37c193296')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
<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.INJECT_ACTIONS" />
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import android.graphics.Paint
|
|||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.RemoteInput
|
||||
import com.roundingmobile.sni.data.local.db.dao.AppActionDao
|
||||
import com.roundingmobile.sni.domain.model.CapturedNotification
|
||||
import com.roundingmobile.sni.domain.repository.NotificationRepository
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
|
@ -30,6 +31,7 @@ class TestBroadcastReceiver : BroadcastReceiver() {
|
|||
|
||||
@Inject lateinit var instrumentation: TestInstrumentation
|
||||
@Inject lateinit var repository: NotificationRepository
|
||||
@Inject lateinit var appActionDao: AppActionDao
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
|
@ -168,6 +170,21 @@ class TestBroadcastReceiver : BroadcastReceiver() {
|
|||
Log.v(TAG, "SET_STATE $key=$value")
|
||||
instrumentation.reportState(key, value)
|
||||
}
|
||||
ACTION_INJECT_ACTIONS -> {
|
||||
val pkg = intent.getStringExtra("package") ?: return
|
||||
val actions = intent.getStringExtra("actions") ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
scope.launch {
|
||||
actions.split(",").map { it.trim() }.forEach { raw ->
|
||||
val isReply = raw.startsWith("@")
|
||||
val label = if (isReply) raw.removePrefix("@") else raw
|
||||
if (label.isNotBlank()) {
|
||||
appActionDao.upsert(pkg, label, isReply, now)
|
||||
}
|
||||
}
|
||||
Log.v(TAG, "EVENT inject_actions package=$pkg actions=\"$actions\"")
|
||||
}
|
||||
}
|
||||
ACTION_TRIGGER_CLEANUP -> {
|
||||
Log.v(TAG, "EVENT trigger_cleanup")
|
||||
instrumentation.reportEvent("trigger_cleanup_request")
|
||||
|
|
@ -374,6 +391,7 @@ class TestBroadcastReceiver : BroadcastReceiver() {
|
|||
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_INJECT_ACTIONS = "${PREFIX}INJECT_ACTIONS"
|
||||
const val ACTION_NOTIFICATION_ACTION_CLICKED = "${PREFIX}ACTION_CLICKED"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,398 @@
|
|||
package com.roundingmobile.sni.data.backup
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import com.roundingmobile.sni.data.local.db.dao.AppFilterDao
|
||||
import com.roundingmobile.sni.data.local.db.dao.FilterRuleDao
|
||||
import com.roundingmobile.sni.data.local.db.dao.HiddenAppDao
|
||||
import com.roundingmobile.sni.data.local.db.dao.NotificationDao
|
||||
import com.roundingmobile.sni.data.local.db.entity.AppFilterEntity
|
||||
import com.roundingmobile.sni.data.local.db.entity.FilterRuleEntity
|
||||
import com.roundingmobile.sni.data.local.db.entity.HiddenAppEntity
|
||||
import com.roundingmobile.sni.data.local.db.entity.NotificationEntity
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable
|
||||
data class BackupMetadata(
|
||||
val version: Int = BACKUP_VERSION,
|
||||
val appVersionName: String,
|
||||
val appVersionCode: Int,
|
||||
val createdAt: Long,
|
||||
val includesDeleted: Boolean,
|
||||
val includesMedia: Boolean,
|
||||
val notificationCount: Int,
|
||||
val filterRuleCount: Int,
|
||||
val hiddenAppCount: Int,
|
||||
val mediaFileCount: Int
|
||||
) {
|
||||
companion object {
|
||||
const val BACKUP_VERSION = 1
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BackupNotification(
|
||||
val id: Long,
|
||||
val packageName: String,
|
||||
val appName: String,
|
||||
val title: String?,
|
||||
val text: String?,
|
||||
val bigText: String?,
|
||||
val category: String?,
|
||||
val priority: Int,
|
||||
val timestamp: Long,
|
||||
val notificationId: Int,
|
||||
val notificationTag: String?,
|
||||
val isUpdate: Boolean,
|
||||
val previousVersionId: Long?,
|
||||
val isRemoved: Boolean,
|
||||
val removedAt: Long?,
|
||||
val removalDelayMs: Long?,
|
||||
val isBookmarked: Boolean,
|
||||
val isRead: Boolean,
|
||||
val extrasJson: String?,
|
||||
val actionsJson: String?,
|
||||
val iconUri: String?,
|
||||
val createdAt: Long,
|
||||
val deletedAt: Long?,
|
||||
val deletionReason: String?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BackupFilterRule(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val action: String,
|
||||
val matchField: String,
|
||||
val matchType: String,
|
||||
val pattern: String,
|
||||
val conditionsJson: String?,
|
||||
val conditionOperator: String,
|
||||
val actionParams: String?,
|
||||
val packageName: String?,
|
||||
val appName: String?,
|
||||
val isEnabled: Boolean,
|
||||
val isBuiltIn: Boolean,
|
||||
val createdAt: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BackupHiddenApp(
|
||||
val packageName: String,
|
||||
val hiddenAt: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BackupPayload(
|
||||
val metadata: BackupMetadata,
|
||||
val notifications: List<BackupNotification>,
|
||||
val filterRules: List<BackupFilterRule>,
|
||||
val hiddenApps: List<BackupHiddenApp>
|
||||
)
|
||||
|
||||
data class BackupOptions(
|
||||
val includeDeleted: Boolean = false,
|
||||
val includeMedia: Boolean = false,
|
||||
val includeFilterRules: Boolean = true,
|
||||
val includeHiddenApps: Boolean = true
|
||||
)
|
||||
|
||||
data class BackupResult(
|
||||
val success: Boolean,
|
||||
val fileName: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
data class RestoreResult(
|
||||
val success: Boolean,
|
||||
val notificationsRestored: Int = 0,
|
||||
val filterRulesRestored: Int = 0,
|
||||
val hiddenAppsRestored: Int = 0,
|
||||
val mediaFilesRestored: Int = 0,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
data class BackupInfo(
|
||||
val metadata: BackupMetadata?,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class BackupManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val notificationDao: NotificationDao,
|
||||
private val filterRuleDao: FilterRuleDao,
|
||||
private val hiddenAppDao: HiddenAppDao,
|
||||
private val appFilterDao: AppFilterDao
|
||||
) {
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
suspend fun createBackup(options: BackupOptions): BackupResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val notifications = if (options.includeDeleted) {
|
||||
notificationDao.getAllIncludingDeleted()
|
||||
} else {
|
||||
notificationDao.getAllActive()
|
||||
}
|
||||
|
||||
val filterRules = if (options.includeFilterRules) {
|
||||
filterRuleDao.getAll()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val hiddenApps = if (options.includeHiddenApps) {
|
||||
hiddenAppDao.getAll()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val mediaDir = File(context.filesDir, "media")
|
||||
val mediaFiles = if (options.includeMedia) {
|
||||
mediaDir.listFiles()?.toList() ?: emptyList()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val metadata = BackupMetadata(
|
||||
appVersionName = com.roundingmobile.sni.BuildConfig.VERSION_NAME,
|
||||
appVersionCode = com.roundingmobile.sni.BuildConfig.VERSION_CODE,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
includesDeleted = options.includeDeleted,
|
||||
includesMedia = options.includeMedia,
|
||||
notificationCount = notifications.size,
|
||||
filterRuleCount = filterRules.size,
|
||||
hiddenAppCount = hiddenApps.size,
|
||||
mediaFileCount = mediaFiles.size
|
||||
)
|
||||
|
||||
val payload = BackupPayload(
|
||||
metadata = metadata,
|
||||
notifications = notifications.map { it.toBackup() },
|
||||
filterRules = filterRules.map { it.toBackup() },
|
||||
hiddenApps = hiddenApps.map { it.toBackup() }
|
||||
)
|
||||
|
||||
val timestamp = java.text.SimpleDateFormat(
|
||||
"yyyyMMdd_HHmmss", java.util.Locale.US
|
||||
).format(java.util.Date())
|
||||
val fileName = "sni_backup_$timestamp.zip"
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.Downloads.MIME_TYPE, "application/zip")
|
||||
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
}
|
||||
val uri = context.contentResolver.insert(
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI, values
|
||||
) ?: return@withContext BackupResult(false, error = "Failed to create file")
|
||||
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
ZipOutputStream(outputStream).use { zip ->
|
||||
// Write JSON data
|
||||
zip.putNextEntry(ZipEntry("backup.json"))
|
||||
zip.write(json.encodeToString(payload).toByteArray(Charsets.UTF_8))
|
||||
zip.closeEntry()
|
||||
|
||||
// Write media files
|
||||
if (options.includeMedia) {
|
||||
for (file in mediaFiles) {
|
||||
zip.putNextEntry(ZipEntry("media/${file.name}"))
|
||||
file.inputStream().use { it.copyTo(zip) }
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BackupResult(success = true, fileName = fileName)
|
||||
} catch (e: Exception) {
|
||||
BackupResult(false, error = e.message ?: "Backup failed")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun peekBackup(inputStream: InputStream): BackupInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
ZipInputStream(BufferedInputStream(inputStream)).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
if (entry.name == "backup.json") {
|
||||
val jsonStr = zip.bufferedReader(Charsets.UTF_8).readText()
|
||||
val payload = json.decodeFromString<BackupPayload>(jsonStr)
|
||||
return@withContext BackupInfo(metadata = payload.metadata)
|
||||
}
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
BackupInfo(metadata = null, error = "Invalid backup: no backup.json found")
|
||||
} catch (e: Exception) {
|
||||
BackupInfo(metadata = null, error = e.message ?: "Failed to read backup")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun restoreBackup(
|
||||
inputStream: InputStream,
|
||||
restoreNotifications: Boolean = true,
|
||||
restoreFilterRules: Boolean = true,
|
||||
restoreHiddenApps: Boolean = true,
|
||||
restoreMedia: Boolean = true
|
||||
): RestoreResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
var notificationsRestored = 0
|
||||
var filterRulesRestored = 0
|
||||
var hiddenAppsRestored = 0
|
||||
var mediaFilesRestored = 0
|
||||
|
||||
val mediaDir = File(context.filesDir, "media").also { it.mkdirs() }
|
||||
|
||||
ZipInputStream(BufferedInputStream(inputStream)).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
when {
|
||||
entry.name == "backup.json" -> {
|
||||
val jsonStr = zip.bufferedReader(Charsets.UTF_8).readText()
|
||||
val payload = json.decodeFromString<BackupPayload>(jsonStr)
|
||||
|
||||
if (restoreNotifications) {
|
||||
val entities = payload.notifications.map { it.toEntity() }
|
||||
// Insert with REPLACE to avoid duplicates on same id
|
||||
notificationDao.insertAll(entities)
|
||||
notificationsRestored = entities.size
|
||||
}
|
||||
|
||||
if (restoreFilterRules) {
|
||||
val entities = payload.filterRules.map { it.toEntity() }
|
||||
filterRuleDao.insertAll(entities)
|
||||
filterRulesRestored = entities.size
|
||||
}
|
||||
|
||||
if (restoreHiddenApps) {
|
||||
val entities = payload.hiddenApps.map { it.toEntity() }
|
||||
hiddenAppDao.insertAll(entities)
|
||||
hiddenAppsRestored = entities.size
|
||||
}
|
||||
}
|
||||
entry.name.startsWith("media/") && restoreMedia -> {
|
||||
val fileName = entry.name.removePrefix("media/")
|
||||
if (fileName.isNotBlank()) {
|
||||
val outFile = File(mediaDir, fileName)
|
||||
outFile.outputStream().use { zip.copyTo(it) }
|
||||
mediaFilesRestored++
|
||||
}
|
||||
}
|
||||
}
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
|
||||
RestoreResult(
|
||||
success = true,
|
||||
notificationsRestored = notificationsRestored,
|
||||
filterRulesRestored = filterRulesRestored,
|
||||
hiddenAppsRestored = hiddenAppsRestored,
|
||||
mediaFilesRestored = mediaFilesRestored
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
RestoreResult(false, error = e.message ?: "Restore failed")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getBackupStats(): BackupStats = withContext(Dispatchers.IO) {
|
||||
val activeCount = notificationDao.getCount()
|
||||
val deletedCount = notificationDao.getDeletedCount()
|
||||
val ruleCount = filterRuleDao.getCount()
|
||||
val hiddenCount = hiddenAppDao.getAllPackageNames().size
|
||||
val mediaDir = File(context.filesDir, "media")
|
||||
val mediaFiles = mediaDir.listFiles() ?: emptyArray()
|
||||
val mediaSizeBytes = mediaFiles.sumOf { it.length() }
|
||||
|
||||
BackupStats(
|
||||
activeNotifications = activeCount,
|
||||
deletedNotifications = deletedCount,
|
||||
filterRules = ruleCount,
|
||||
hiddenApps = hiddenCount,
|
||||
mediaFiles = mediaFiles.size,
|
||||
mediaSizeBytes = mediaSizeBytes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class BackupStats(
|
||||
val activeNotifications: Int,
|
||||
val deletedNotifications: Int,
|
||||
val filterRules: Int,
|
||||
val hiddenApps: Int,
|
||||
val mediaFiles: Int,
|
||||
val mediaSizeBytes: Long
|
||||
)
|
||||
|
||||
// --- Mapping functions ---
|
||||
|
||||
private fun NotificationEntity.toBackup() = BackupNotification(
|
||||
id = id, packageName = packageName, appName = appName,
|
||||
title = title, text = text, bigText = bigText,
|
||||
category = category, priority = priority, timestamp = timestamp,
|
||||
notificationId = notificationId, notificationTag = notificationTag,
|
||||
isUpdate = isUpdate, previousVersionId = previousVersionId,
|
||||
isRemoved = isRemoved, removedAt = removedAt, removalDelayMs = removalDelayMs,
|
||||
isBookmarked = isBookmarked, isRead = isRead,
|
||||
extrasJson = extrasJson, actionsJson = actionsJson,
|
||||
iconUri = iconUri, createdAt = createdAt,
|
||||
deletedAt = deletedAt, deletionReason = deletionReason
|
||||
)
|
||||
|
||||
private fun BackupNotification.toEntity() = NotificationEntity(
|
||||
id = id, packageName = packageName, appName = appName,
|
||||
title = title, text = text, bigText = bigText,
|
||||
category = category, priority = priority, timestamp = timestamp,
|
||||
notificationId = notificationId, notificationTag = notificationTag,
|
||||
isUpdate = isUpdate, previousVersionId = previousVersionId,
|
||||
isRemoved = isRemoved, removedAt = removedAt, removalDelayMs = removalDelayMs,
|
||||
isBookmarked = isBookmarked, isRead = isRead,
|
||||
extrasJson = extrasJson, actionsJson = actionsJson,
|
||||
iconUri = iconUri, createdAt = createdAt,
|
||||
deletedAt = deletedAt, deletionReason = deletionReason
|
||||
)
|
||||
|
||||
private fun FilterRuleEntity.toBackup() = BackupFilterRule(
|
||||
id = id, name = name, action = action,
|
||||
matchField = matchField, matchType = matchType, pattern = pattern,
|
||||
conditionsJson = conditionsJson, conditionOperator = conditionOperator,
|
||||
actionParams = actionParams, packageName = packageName, appName = appName,
|
||||
isEnabled = isEnabled, isBuiltIn = isBuiltIn, createdAt = createdAt
|
||||
)
|
||||
|
||||
private fun BackupFilterRule.toEntity() = FilterRuleEntity(
|
||||
id = id, name = name, action = action,
|
||||
matchField = matchField, matchType = matchType, pattern = pattern,
|
||||
conditionsJson = conditionsJson, conditionOperator = conditionOperator,
|
||||
actionParams = actionParams, packageName = packageName, appName = appName,
|
||||
isEnabled = isEnabled, isBuiltIn = isBuiltIn, createdAt = createdAt
|
||||
)
|
||||
|
||||
private fun HiddenAppEntity.toBackup() = BackupHiddenApp(
|
||||
packageName = packageName, hiddenAt = hiddenAt
|
||||
)
|
||||
|
||||
private fun BackupHiddenApp.toEntity() = HiddenAppEntity(
|
||||
packageName = packageName, hiddenAt = hiddenAt
|
||||
)
|
||||
|
|
@ -5,6 +5,7 @@ import androidx.room.Room
|
|||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.roundingmobile.sni.data.local.db.AppDatabase
|
||||
import com.roundingmobile.sni.data.local.db.dao.AppActionDao
|
||||
import com.roundingmobile.sni.data.local.db.dao.AppFilterDao
|
||||
import com.roundingmobile.sni.data.local.db.dao.FilterRuleDao
|
||||
import com.roundingmobile.sni.data.local.db.dao.HiddenAppDao
|
||||
|
|
@ -83,6 +84,22 @@ object DatabaseModule {
|
|||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS app_actions (
|
||||
package_name TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
has_remote_input INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
seen_count INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY(package_name, label)
|
||||
)
|
||||
""".trimIndent())
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_app_actions_package_name ON app_actions(package_name)")
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
|
||||
|
|
@ -90,9 +107,10 @@ object DatabaseModule {
|
|||
context,
|
||||
AppDatabase::class.java,
|
||||
"sni.db"
|
||||
).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7)
|
||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||
.build()
|
||||
).addMigrations(
|
||||
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4,
|
||||
MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8
|
||||
).build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
|
@ -106,4 +124,7 @@ object DatabaseModule {
|
|||
|
||||
@Provides
|
||||
fun provideFilterRuleDao(db: AppDatabase): FilterRuleDao = db.filterRuleDao()
|
||||
|
||||
@Provides
|
||||
fun provideAppActionDao(db: AppDatabase): AppActionDao = db.appActionDao()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
package com.roundingmobile.sni.data.di
|
||||
|
||||
import com.roundingmobile.sni.data.provider.MediaCleanupProviderImpl
|
||||
import com.roundingmobile.sni.data.provider.ProStatusProviderImpl
|
||||
import com.roundingmobile.sni.data.provider.RetentionPreferencesImpl
|
||||
import com.roundingmobile.sni.data.repository.FilterRuleRepositoryImpl
|
||||
import com.roundingmobile.sni.data.repository.NotificationRepositoryImpl
|
||||
import com.roundingmobile.sni.domain.provider.MediaCleanupProvider
|
||||
import com.roundingmobile.sni.domain.provider.ProStatusProvider
|
||||
import com.roundingmobile.sni.domain.provider.RetentionPreferences
|
||||
import com.roundingmobile.sni.domain.repository.FilterRuleRepository
|
||||
import com.roundingmobile.sni.domain.repository.NotificationRepository
|
||||
import dagger.Binds
|
||||
|
|
@ -26,4 +30,12 @@ abstract class RepositoryModule {
|
|||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindProStatusProvider(impl: ProStatusProviderImpl): ProStatusProvider
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindRetentionPreferences(impl: RetentionPreferencesImpl): RetentionPreferences
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindMediaCleanupProvider(impl: MediaCleanupProviderImpl): MediaCleanupProvider
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class FileExporter @Inject constructor(
|
|||
)
|
||||
if (uri != null) {
|
||||
context.contentResolver.openOutputStream(uri)?.use {
|
||||
it.write(content.toByteArray())
|
||||
it.write(content.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
true
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ package com.roundingmobile.sni.data.local.db
|
|||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import com.roundingmobile.sni.data.local.db.dao.AppActionDao
|
||||
import com.roundingmobile.sni.data.local.db.dao.AppFilterDao
|
||||
import com.roundingmobile.sni.data.local.db.dao.FilterRuleDao
|
||||
import com.roundingmobile.sni.data.local.db.dao.HiddenAppDao
|
||||
import com.roundingmobile.sni.data.local.db.dao.NotificationDao
|
||||
import com.roundingmobile.sni.data.local.db.entity.AppActionEntity
|
||||
import com.roundingmobile.sni.data.local.db.entity.AppFilterEntity
|
||||
import com.roundingmobile.sni.data.local.db.entity.FilterRuleEntity
|
||||
import com.roundingmobile.sni.data.local.db.entity.HiddenAppEntity
|
||||
|
|
@ -18,9 +20,10 @@ import com.roundingmobile.sni.data.local.db.entity.NotificationFtsEntity
|
|||
NotificationFtsEntity::class,
|
||||
HiddenAppEntity::class,
|
||||
AppFilterEntity::class,
|
||||
FilterRuleEntity::class
|
||||
FilterRuleEntity::class,
|
||||
AppActionEntity::class
|
||||
],
|
||||
version = 7,
|
||||
version = 8,
|
||||
exportSchema = true
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
|
@ -28,4 +31,5 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
abstract fun hiddenAppDao(): HiddenAppDao
|
||||
abstract fun appFilterDao(): AppFilterDao
|
||||
abstract fun filterRuleDao(): FilterRuleDao
|
||||
abstract fun appActionDao(): AppActionDao
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
package com.roundingmobile.sni.data.local.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import com.roundingmobile.sni.data.local.db.entity.AppActionEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface AppActionDao {
|
||||
|
||||
@Query("""
|
||||
INSERT INTO app_actions (package_name, label, has_remote_input, last_seen_at, seen_count)
|
||||
VALUES (:packageName, :label, :hasRemoteInput, :lastSeenAt, 1)
|
||||
ON CONFLICT(package_name, label) DO UPDATE SET
|
||||
has_remote_input = :hasRemoteInput,
|
||||
last_seen_at = :lastSeenAt,
|
||||
seen_count = seen_count + 1
|
||||
""")
|
||||
suspend fun upsert(packageName: String, label: String, hasRemoteInput: Boolean, lastSeenAt: Long)
|
||||
|
||||
@Query("""
|
||||
SELECT * FROM app_actions
|
||||
WHERE package_name = :packageName AND has_remote_input = 0
|
||||
ORDER BY seen_count DESC
|
||||
""")
|
||||
fun getNonReplyActionsFlow(packageName: String): Flow<List<AppActionEntity>>
|
||||
|
||||
@Query("DELETE FROM app_actions WHERE package_name = :packageName")
|
||||
suspend fun deleteByPackage(packageName: String)
|
||||
|
||||
@Query("DELETE FROM app_actions")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import androidx.room.Insert
|
|||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.roundingmobile.sni.data.local.db.entity.AppFilterEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface AppFilterDao {
|
||||
|
|
@ -16,9 +15,6 @@ interface AppFilterDao {
|
|||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(entities: List<AppFilterEntity>)
|
||||
|
||||
@Query("SELECT * FROM app_filter_prefs")
|
||||
fun getAllFlow(): Flow<List<AppFilterEntity>>
|
||||
|
||||
@Query("SELECT package_name FROM app_filter_prefs WHERE is_visible = 0")
|
||||
suspend fun getHiddenFilterPackages(): List<String>
|
||||
|
||||
|
|
|
|||
|
|
@ -37,4 +37,10 @@ interface FilterRuleDao {
|
|||
|
||||
@Query("UPDATE filter_rules SET is_enabled = :enabled WHERE id = :id")
|
||||
suspend fun setEnabled(id: Long, enabled: Boolean)
|
||||
|
||||
@Query("SELECT * FROM filter_rules ORDER BY created_at DESC")
|
||||
suspend fun getAll(): List<FilterRuleEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(entities: List<FilterRuleEntity>)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,4 +24,10 @@ interface HiddenAppDao {
|
|||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM hidden_apps WHERE package_name = :packageName)")
|
||||
suspend fun isHidden(packageName: String): Boolean
|
||||
|
||||
@Query("SELECT * FROM hidden_apps")
|
||||
suspend fun getAll(): List<HiddenAppEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(entities: List<HiddenAppEntity>)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,6 @@ interface NotificationDao {
|
|||
@Update
|
||||
suspend fun update(entity: NotificationEntity)
|
||||
|
||||
@Query("SELECT * FROM notifications WHERE deleted_at IS NULL ORDER BY timestamp DESC")
|
||||
fun getAllFlow(): Flow<List<NotificationEntity>>
|
||||
|
||||
@Query("SELECT * FROM notifications WHERE id = :id")
|
||||
suspend fun getById(id: Long): NotificationEntity?
|
||||
|
||||
|
|
@ -126,10 +123,19 @@ interface NotificationDao {
|
|||
@Query("SELECT COUNT(*) FROM notifications WHERE deleted_at IS NOT NULL")
|
||||
fun getDeletedCountFlow(): Flow<Int>
|
||||
|
||||
// --- Hard delete (retention cleanup) ---
|
||||
@Query("SELECT * FROM notifications WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC")
|
||||
fun getDeletedFlow(): Flow<List<NotificationEntity>>
|
||||
|
||||
@Query("DELETE FROM notifications WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
@Query("UPDATE notifications SET deleted_at = NULL, deletion_reason = NULL WHERE deleted_at IS NOT NULL")
|
||||
suspend fun restoreAllDeleted()
|
||||
|
||||
@Query("DELETE FROM notifications WHERE deleted_at IS NOT NULL")
|
||||
suspend fun permanentlyDeleteAll()
|
||||
|
||||
@Query("DELETE FROM notifications WHERE id = :id AND deleted_at IS NOT NULL")
|
||||
suspend fun permanentlyDeleteById(id: Long)
|
||||
|
||||
// --- Hard delete (retention cleanup) ---
|
||||
|
||||
@Query("DELETE FROM notifications WHERE timestamp < :before AND deleted_at IS NULL")
|
||||
suspend fun deleteOlderThan(before: Long): Int
|
||||
|
|
@ -140,26 +146,41 @@ interface NotificationDao {
|
|||
@Query("DELETE FROM notifications")
|
||||
suspend fun deleteAll()
|
||||
|
||||
@Query("SELECT DISTINCT package_name, app_name FROM notifications WHERE deleted_at IS NULL ORDER BY app_name")
|
||||
fun getDistinctApps(): Flow<List<AppNameTuple>>
|
||||
|
||||
@Query("SELECT package_name, COUNT(*) as count FROM notifications WHERE deleted_at IS NULL GROUP BY package_name ORDER BY count DESC")
|
||||
fun getAppCounts(): Flow<List<AppCountTuple>>
|
||||
|
||||
@Query("SELECT package_name, app_name, COUNT(*) as count FROM notifications WHERE deleted_at IS NULL GROUP BY package_name ORDER BY count DESC")
|
||||
fun getAppInfos(): Flow<List<AppInfoTuple>>
|
||||
|
||||
// --- Adjacent navigation ---
|
||||
|
||||
@Query("""
|
||||
SELECT id FROM notifications
|
||||
WHERE deleted_at IS NULL
|
||||
AND package_name NOT IN (SELECT package_name FROM hidden_apps)
|
||||
AND timestamp < (SELECT timestamp FROM notifications WHERE id = :currentId)
|
||||
ORDER BY timestamp DESC LIMIT 1
|
||||
""")
|
||||
suspend fun getNextId(currentId: Long): Long?
|
||||
|
||||
@Query("""
|
||||
SELECT id FROM notifications
|
||||
WHERE deleted_at IS NULL
|
||||
AND package_name NOT IN (SELECT package_name FROM hidden_apps)
|
||||
AND timestamp > (SELECT timestamp FROM notifications WHERE id = :currentId)
|
||||
ORDER BY timestamp ASC LIMIT 1
|
||||
""")
|
||||
suspend fun getPrevId(currentId: Long): Long?
|
||||
|
||||
// --- Backup ---
|
||||
|
||||
@Query("SELECT * FROM notifications WHERE deleted_at IS NULL ORDER BY timestamp DESC")
|
||||
suspend fun getAllActive(): List<NotificationEntity>
|
||||
|
||||
@Query("SELECT * FROM notifications ORDER BY timestamp DESC")
|
||||
suspend fun getAllIncludingDeleted(): List<NotificationEntity>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM notifications WHERE deleted_at IS NOT NULL")
|
||||
suspend fun getDeletedCount(): Int
|
||||
}
|
||||
|
||||
data class AppNameTuple(
|
||||
val package_name: String,
|
||||
val app_name: String
|
||||
)
|
||||
|
||||
data class AppCountTuple(
|
||||
val package_name: String,
|
||||
val count: Int
|
||||
)
|
||||
|
||||
data class AppInfoTuple(
|
||||
val package_name: String,
|
||||
val app_name: String,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
package com.roundingmobile.sni.data.local.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
|
||||
@Entity(
|
||||
tableName = "app_actions",
|
||||
primaryKeys = ["package_name", "label"],
|
||||
indices = [Index(value = ["package_name"])]
|
||||
)
|
||||
data class AppActionEntity(
|
||||
@ColumnInfo(name = "package_name") val packageName: String,
|
||||
val label: String,
|
||||
@ColumnInfo(name = "has_remote_input") val hasRemoteInput: Boolean = false,
|
||||
@ColumnInfo(name = "last_seen_at") val lastSeenAt: Long,
|
||||
@ColumnInfo(name = "seen_count") val seenCount: Int = 1
|
||||
)
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.roundingmobile.sni.data.provider
|
||||
|
||||
import android.content.Context
|
||||
import com.roundingmobile.sni.domain.provider.MediaCleanupProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class MediaCleanupProviderImpl @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) : MediaCleanupProvider {
|
||||
override fun cleanMediaOlderThan(cutoffMs: Long) {
|
||||
val mediaDir = File(context.filesDir, "media")
|
||||
mediaDir.listFiles()?.forEach { file ->
|
||||
if (file.lastModified() < cutoffMs) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ class ProStatusProviderImpl @Inject constructor(
|
|||
) : ProStatusProvider {
|
||||
|
||||
override val isPro: Boolean
|
||||
get() = prefs.getBoolean(KEY_IS_PRO, false)
|
||||
get() = prefs.getBoolean(KEY_IS_PRO, com.roundingmobile.sni.BuildConfig.DEBUG)
|
||||
|
||||
override val isProFlow: Flow<Boolean> = callbackFlow {
|
||||
trySend(isPro)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
package com.roundingmobile.sni.data.provider
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.roundingmobile.sni.domain.provider.RetentionPreferences
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class RetentionPreferencesImpl @Inject constructor(
|
||||
private val prefs: SharedPreferences
|
||||
) : RetentionPreferences {
|
||||
override fun getRetentionDays(): Int = prefs.getInt("retention_days", 30)
|
||||
override fun getMediaRetentionDays(): Int = prefs.getInt("media_retention_days", 0)
|
||||
}
|
||||
|
|
@ -36,7 +36,10 @@ class NotificationRepositoryImpl @Inject constructor(
|
|||
}
|
||||
|
||||
override fun search(query: String): Flow<List<CapturedNotification>> {
|
||||
val sanitized = query.replace(Regex("[\"*()\\-:^~]"), "").trim()
|
||||
val sanitized = query
|
||||
.replace(Regex("[\"*()\\-:^~{}]"), "")
|
||||
.replace(Regex("\\b(AND|OR|NOT|NEAR)\\b", RegexOption.IGNORE_CASE), "")
|
||||
.trim()
|
||||
if (sanitized.isBlank()) return kotlinx.coroutines.flow.flowOf(emptyList())
|
||||
return notificationDao.search("$sanitized*").map { list -> list.map { it.toDomain() } }
|
||||
}
|
||||
|
|
@ -116,6 +119,15 @@ class NotificationRepositoryImpl @Inject constructor(
|
|||
|
||||
override fun getDeletedCountFlow(): Flow<Int> = notificationDao.getDeletedCountFlow()
|
||||
|
||||
override fun getDeletedNotifications(): Flow<List<CapturedNotification>> =
|
||||
notificationDao.getDeletedFlow().map { list -> list.map { it.toDomain() } }
|
||||
|
||||
override suspend fun restoreAllDeleted() = notificationDao.restoreAllDeleted()
|
||||
|
||||
override suspend fun permanentlyDeleteAllDeleted() = notificationDao.permanentlyDeleteAll()
|
||||
|
||||
override suspend fun permanentlyDeleteById(id: Long) = notificationDao.permanentlyDeleteById(id)
|
||||
|
||||
override suspend fun countOlderThan(before: Long): Int =
|
||||
notificationDao.countOlderThan(before)
|
||||
|
||||
|
|
@ -124,6 +136,9 @@ class NotificationRepositoryImpl @Inject constructor(
|
|||
|
||||
override suspend fun deleteOlderThan(before: Long): Int = notificationDao.deleteOlderThan(before)
|
||||
|
||||
override suspend fun getNextId(currentId: Long): Long? = notificationDao.getNextId(currentId)
|
||||
override suspend fun getPrevId(currentId: Long): Long? = notificationDao.getPrevId(currentId)
|
||||
|
||||
override suspend fun deleteAll() = notificationDao.deleteAll()
|
||||
|
||||
// --- App filter preferences ---
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package com.roundingmobile.sni.domain.provider
|
||||
|
||||
interface MediaCleanupProvider {
|
||||
fun cleanMediaOlderThan(cutoffMs: Long)
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.roundingmobile.sni.domain.provider
|
||||
|
||||
interface RetentionPreferences {
|
||||
fun getRetentionDays(): Int
|
||||
fun getMediaRetentionDays(): Int
|
||||
}
|
||||
|
|
@ -30,9 +30,15 @@ interface NotificationRepository {
|
|||
suspend fun undoSoftDeleteByIds(ids: List<Long>)
|
||||
suspend fun setDeletionReason(id: Long, reason: String)
|
||||
fun getDeletedCountFlow(): Flow<Int>
|
||||
fun getDeletedNotifications(): Flow<List<CapturedNotification>>
|
||||
suspend fun restoreAllDeleted()
|
||||
suspend fun permanentlyDeleteAllDeleted()
|
||||
suspend fun permanentlyDeleteById(id: Long)
|
||||
suspend fun countOlderThan(before: Long): Int
|
||||
suspend fun purgeDeletedOlderThan(before: Long): Int
|
||||
suspend fun deleteOlderThan(before: Long): Int
|
||||
suspend fun getNextId(currentId: Long): Long?
|
||||
suspend fun getPrevId(currentId: Long): Long?
|
||||
suspend fun deleteAll()
|
||||
|
||||
// App filter preferences (timeline visibility)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.roundingmobile.sni.domain.usecase
|
|||
|
||||
import com.roundingmobile.sni.domain.model.CapturedNotification
|
||||
import com.roundingmobile.sni.domain.model.ExportFormat
|
||||
import com.roundingmobile.sni.domain.model.SortOrder
|
||||
import com.roundingmobile.sni.domain.repository.NotificationRepository
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
|
|
@ -10,9 +11,7 @@ class ExportNotificationsUseCase @Inject constructor(
|
|||
private val repository: NotificationRepository
|
||||
) {
|
||||
suspend operator fun invoke(format: ExportFormat): String {
|
||||
val notifications = repository.getTimeline(
|
||||
com.roundingmobile.sni.domain.model.SortOrder.NEWEST_FIRST
|
||||
).first()
|
||||
val notifications = repository.getTimeline(SortOrder.NEWEST_FIRST).first()
|
||||
return when (format) {
|
||||
ExportFormat.CSV -> toCsv(notifications)
|
||||
ExportFormat.JSON -> toJson(notifications)
|
||||
|
|
@ -27,14 +26,24 @@ class ExportNotificationsUseCase @Inject constructor(
|
|||
}
|
||||
|
||||
private fun escapeJsonString(value: String): String {
|
||||
return value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
.replace("\b", "\\b")
|
||||
.replace("\u000C", "\\f")
|
||||
return buildString(value.length) {
|
||||
for (ch in value) {
|
||||
when (ch) {
|
||||
'\\' -> append("\\\\")
|
||||
'"' -> append("\\\"")
|
||||
'\n' -> append("\\n")
|
||||
'\r' -> append("\\r")
|
||||
'\t' -> append("\\t")
|
||||
'\b' -> append("\\b")
|
||||
'\u000C' -> append("\\f")
|
||||
else -> if (ch.code < 0x20) {
|
||||
append("\\u%04x".format(ch.code))
|
||||
} else {
|
||||
append(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toCsv(notifications: List<CapturedNotification>): String = buildString {
|
||||
|
|
|
|||
|
|
@ -18,18 +18,22 @@ data class RuleResult(
|
|||
@Singleton
|
||||
class FilterRuleEngine @Inject constructor() {
|
||||
|
||||
private val regexCache = object : LinkedHashMap<String, Result<Regex>>(16, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Result<Regex>>?): Boolean {
|
||||
return size > MAX_CACHE_SIZE
|
||||
private val regexCache = java.util.Collections.synchronizedMap(
|
||||
object : LinkedHashMap<String, Result<Regex>>(16, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Result<Regex>>?): Boolean {
|
||||
return size > MAX_CACHE_SIZE
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
private fun getCachedRegex(pattern: String): Regex? {
|
||||
return regexCache.getOrPut(pattern) {
|
||||
try {
|
||||
Result.success(Regex(pattern))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
return synchronized(regexCache) {
|
||||
regexCache.getOrPut(pattern) {
|
||||
try {
|
||||
Result.success(Regex(pattern))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
|
@ -66,7 +70,7 @@ class FilterRuleEngine @Inject constructor() {
|
|||
title: String?,
|
||||
text: String?
|
||||
): Boolean {
|
||||
if (rule.conditions.isEmpty()) return false
|
||||
if (rule.conditions.isEmpty()) return rule.packageName != null
|
||||
return when (rule.conditionOperator) {
|
||||
ConditionOperator.AND -> rule.conditions.all {
|
||||
matchesCondition(it, packageName, appName, title, text)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
package com.roundingmobile.sni.domain.usecase
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.roundingmobile.sni.domain.provider.MediaCleanupProvider
|
||||
import com.roundingmobile.sni.domain.provider.RetentionPreferences
|
||||
import com.roundingmobile.sni.domain.repository.NotificationRepository
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class RetentionCleanupUseCase @Inject constructor(
|
||||
private val repository: NotificationRepository,
|
||||
private val prefs: SharedPreferences
|
||||
private val retentionPrefs: RetentionPreferences,
|
||||
private val mediaCleanup: MediaCleanupProvider
|
||||
) {
|
||||
suspend fun execute(mediaDir: File) {
|
||||
val retentionDays = prefs.getInt("retention_days", 30)
|
||||
suspend fun execute() {
|
||||
val retentionDays = retentionPrefs.getRetentionDays()
|
||||
|
||||
// Notification cleanup
|
||||
if (retentionDays != 0) {
|
||||
|
|
@ -22,7 +23,7 @@ class RetentionCleanupUseCase @Inject constructor(
|
|||
}
|
||||
|
||||
// Media cleanup — uses its own retention, capped by notification retention
|
||||
val mediaRetentionDays = prefs.getInt("media_retention_days", 0)
|
||||
val mediaRetentionDays = retentionPrefs.getMediaRetentionDays()
|
||||
val effectiveMediaDays = when {
|
||||
mediaRetentionDays == 0 -> retentionDays // Same as notifications
|
||||
retentionDays == 0 -> mediaRetentionDays // Notifications unlimited, media has limit
|
||||
|
|
@ -30,15 +31,7 @@ class RetentionCleanupUseCase @Inject constructor(
|
|||
}
|
||||
if (effectiveMediaDays != 0) {
|
||||
val mediaCutoff = System.currentTimeMillis() - (effectiveMediaDays.toLong() * 24 * 60 * 60 * 1000)
|
||||
cleanMediaFiles(mediaDir, mediaCutoff)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanMediaFiles(mediaDir: File, cutoff: Long) {
|
||||
mediaDir.listFiles()?.forEach { file ->
|
||||
if (file.lastModified() < cutoff) {
|
||||
file.delete()
|
||||
}
|
||||
mediaCleanup.cleanMediaOlderThan(mediaCutoff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
|
|
|
|||
|
|
@ -0,0 +1,461 @@
|
|||
package com.roundingmobile.sni.presentation.backup
|
||||
|
||||
import android.net.Uri
|
||||
import android.text.format.Formatter
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Backup
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.FilterAlt
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.roundingmobile.sni.R
|
||||
import com.roundingmobile.sni.data.backup.BackupOptions
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BackupScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: BackupViewModel = hiltViewModel()
|
||||
) {
|
||||
val stats by viewModel.stats.collectAsStateWithLifecycle()
|
||||
val backupState by viewModel.backupState.collectAsStateWithLifecycle()
|
||||
val restorePreview by viewModel.restorePreview.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
var includeDeleted by remember { mutableStateOf(false) }
|
||||
var includeMedia by remember { mutableStateOf(true) }
|
||||
var includeFilterRules by remember { mutableStateOf(true) }
|
||||
var includeHiddenApps by remember { mutableStateOf(true) }
|
||||
|
||||
// Restore options
|
||||
var restoreUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var showRestoreDialog by remember { mutableStateOf(false) }
|
||||
var restoreNotifications by remember { mutableStateOf(true) }
|
||||
var restoreRules by remember { mutableStateOf(true) }
|
||||
var restoreHidden by remember { mutableStateOf(true) }
|
||||
var restoreMedia by remember { mutableStateOf(true) }
|
||||
|
||||
val filePicker = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
restoreUri = uri
|
||||
viewModel.peekBackup(uri, context.contentResolver)
|
||||
showRestoreDialog = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(backupState) {
|
||||
when (val state = backupState) {
|
||||
is BackupUiState.BackupSuccess -> {
|
||||
snackbarHostState.showSnackbar(
|
||||
context.getString(R.string.backup_saved_to, state.fileName)
|
||||
)
|
||||
viewModel.resetState()
|
||||
}
|
||||
is BackupUiState.RestoreSuccess -> {
|
||||
val r = state.result
|
||||
snackbarHostState.showSnackbar(
|
||||
context.getString(
|
||||
R.string.backup_restore_complete,
|
||||
r.notificationsRestored,
|
||||
r.filterRulesRestored
|
||||
)
|
||||
)
|
||||
viewModel.resetState()
|
||||
}
|
||||
is BackupUiState.Error -> {
|
||||
snackbarHostState.showSnackbar(state.message)
|
||||
viewModel.resetState()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.backup_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.cd_back)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
modifier = modifier
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// --- Current data summary ---
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.backup_current_data),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
if (stats != null) {
|
||||
val s = stats!!
|
||||
StatRow(
|
||||
Icons.Default.Notifications,
|
||||
stringResource(R.string.backup_notifications),
|
||||
s.activeNotifications.toString()
|
||||
)
|
||||
if (s.deletedNotifications > 0) {
|
||||
StatRow(
|
||||
Icons.Default.DeleteSweep,
|
||||
stringResource(R.string.backup_deleted_items),
|
||||
s.deletedNotifications.toString()
|
||||
)
|
||||
}
|
||||
StatRow(
|
||||
Icons.Default.FilterAlt,
|
||||
stringResource(R.string.backup_filter_rules),
|
||||
s.filterRules.toString()
|
||||
)
|
||||
StatRow(
|
||||
Icons.Default.VisibilityOff,
|
||||
stringResource(R.string.backup_hidden_apps),
|
||||
s.hiddenApps.toString()
|
||||
)
|
||||
if (s.mediaFiles > 0) {
|
||||
StatRow(
|
||||
Icons.Default.Image,
|
||||
stringResource(R.string.backup_media_files),
|
||||
"${s.mediaFiles} (${Formatter.formatShortFileSize(context, s.mediaSizeBytes)})"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// --- Backup section ---
|
||||
Text(
|
||||
stringResource(R.string.backup_create_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
OptionCheckbox(
|
||||
label = stringResource(R.string.backup_include_deleted),
|
||||
subtitle = stats?.let {
|
||||
stringResource(R.string.backup_include_deleted_count, it.deletedNotifications)
|
||||
},
|
||||
checked = includeDeleted,
|
||||
onCheckedChange = { includeDeleted = it }
|
||||
)
|
||||
|
||||
OptionCheckbox(
|
||||
label = stringResource(R.string.backup_include_media),
|
||||
subtitle = stats?.let {
|
||||
if (it.mediaFiles > 0) {
|
||||
stringResource(
|
||||
R.string.backup_include_media_count,
|
||||
it.mediaFiles,
|
||||
Formatter.formatShortFileSize(context, it.mediaSizeBytes)
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.backup_no_media)
|
||||
}
|
||||
},
|
||||
checked = includeMedia,
|
||||
onCheckedChange = { includeMedia = it }
|
||||
)
|
||||
|
||||
OptionCheckbox(
|
||||
label = stringResource(R.string.backup_include_filter_rules),
|
||||
subtitle = stats?.let {
|
||||
stringResource(R.string.backup_include_rules_count, it.filterRules)
|
||||
},
|
||||
checked = includeFilterRules,
|
||||
onCheckedChange = { includeFilterRules = it }
|
||||
)
|
||||
|
||||
OptionCheckbox(
|
||||
label = stringResource(R.string.backup_include_hidden_apps),
|
||||
subtitle = stats?.let {
|
||||
stringResource(R.string.backup_include_hidden_count, it.hiddenApps)
|
||||
},
|
||||
checked = includeHiddenApps,
|
||||
onCheckedChange = { includeHiddenApps = it }
|
||||
)
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
viewModel.createBackup(
|
||||
BackupOptions(
|
||||
includeDeleted = includeDeleted,
|
||||
includeMedia = includeMedia,
|
||||
includeFilterRules = includeFilterRules,
|
||||
includeHiddenApps = includeHiddenApps
|
||||
)
|
||||
)
|
||||
},
|
||||
enabled = backupState != BackupUiState.Working,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (backupState == BackupUiState.Working) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(end = 8.dp))
|
||||
} else {
|
||||
Icon(Icons.Default.Backup, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
|
||||
}
|
||||
Text(stringResource(R.string.backup_create_button))
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// --- Restore section ---
|
||||
Text(
|
||||
stringResource(R.string.backup_restore_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.backup_restore_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = { filePicker.launch(arrayOf("application/zip")) },
|
||||
enabled = backupState != BackupUiState.Working,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
|
||||
Text(stringResource(R.string.backup_restore_button))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Restore confirmation dialog ---
|
||||
if (showRestoreDialog && restoreUri != null) {
|
||||
val preview = restorePreview
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showRestoreDialog = false
|
||||
viewModel.clearRestorePreview()
|
||||
},
|
||||
title = { Text(stringResource(R.string.backup_restore_confirm_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (preview?.metadata != null) {
|
||||
val m = preview.metadata
|
||||
Text(
|
||||
stringResource(R.string.backup_restore_info, m.notificationCount, m.filterRuleCount),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
if (m.includesDeleted) {
|
||||
Text(
|
||||
stringResource(R.string.backup_restore_has_deleted),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (m.includesMedia && m.mediaFileCount > 0) {
|
||||
Text(
|
||||
stringResource(R.string.backup_restore_has_media, m.mediaFileCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
} else if (preview?.error != null) {
|
||||
Text(
|
||||
preview.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.backup_restore_what_to_restore),
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
OptionCheckbox(
|
||||
label = stringResource(R.string.backup_notifications),
|
||||
checked = restoreNotifications,
|
||||
onCheckedChange = { restoreNotifications = it }
|
||||
)
|
||||
OptionCheckbox(
|
||||
label = stringResource(R.string.backup_filter_rules),
|
||||
checked = restoreRules,
|
||||
onCheckedChange = { restoreRules = it }
|
||||
)
|
||||
OptionCheckbox(
|
||||
label = stringResource(R.string.backup_hidden_apps),
|
||||
checked = restoreHidden,
|
||||
onCheckedChange = { restoreHidden = it }
|
||||
)
|
||||
if (preview?.metadata?.includesMedia == true && preview.metadata.mediaFileCount > 0) {
|
||||
OptionCheckbox(
|
||||
label = stringResource(R.string.backup_media_files),
|
||||
checked = restoreMedia,
|
||||
onCheckedChange = { restoreMedia = it }
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.backup_restore_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showRestoreDialog = false
|
||||
viewModel.restoreBackup(
|
||||
restoreUri!!,
|
||||
context.contentResolver,
|
||||
restoreNotifications,
|
||||
restoreRules,
|
||||
restoreHidden,
|
||||
restoreMedia
|
||||
)
|
||||
viewModel.clearRestorePreview()
|
||||
},
|
||||
enabled = preview?.metadata != null
|
||||
) {
|
||||
Text(stringResource(R.string.backup_restore_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
showRestoreDialog = false
|
||||
viewModel.clearRestorePreview()
|
||||
}) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatRow(icon: ImageVector, label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OptionCheckbox(
|
||||
label: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
subtitle: String? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(checked = checked, onCheckedChange = onCheckedChange)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package com.roundingmobile.sni.presentation.backup
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.roundingmobile.sni.data.backup.BackupInfo
|
||||
import com.roundingmobile.sni.data.backup.BackupManager
|
||||
import com.roundingmobile.sni.data.backup.BackupOptions
|
||||
import com.roundingmobile.sni.data.backup.BackupStats
|
||||
import com.roundingmobile.sni.data.backup.RestoreResult
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class BackupViewModel @Inject constructor(
|
||||
private val backupManager: BackupManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _stats = MutableStateFlow<BackupStats?>(null)
|
||||
val stats: StateFlow<BackupStats?> = _stats.asStateFlow()
|
||||
|
||||
private val _backupState = MutableStateFlow<BackupUiState>(BackupUiState.Idle)
|
||||
val backupState: StateFlow<BackupUiState> = _backupState.asStateFlow()
|
||||
|
||||
private val _restorePreview = MutableStateFlow<BackupInfo?>(null)
|
||||
val restorePreview: StateFlow<BackupInfo?> = _restorePreview.asStateFlow()
|
||||
|
||||
init {
|
||||
refreshStats()
|
||||
}
|
||||
|
||||
fun refreshStats() {
|
||||
viewModelScope.launch {
|
||||
_stats.value = backupManager.getBackupStats()
|
||||
}
|
||||
}
|
||||
|
||||
fun createBackup(options: BackupOptions) {
|
||||
_backupState.value = BackupUiState.Working
|
||||
viewModelScope.launch {
|
||||
val result = backupManager.createBackup(options)
|
||||
_backupState.value = if (result.success) {
|
||||
BackupUiState.BackupSuccess(result.fileName ?: "backup.zip")
|
||||
} else {
|
||||
BackupUiState.Error(result.error ?: "Backup failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun peekBackup(uri: Uri, contentResolver: android.content.ContentResolver) {
|
||||
viewModelScope.launch {
|
||||
val inputStream = contentResolver.openInputStream(uri)
|
||||
if (inputStream == null) {
|
||||
_restorePreview.value = BackupInfo(null, "Cannot open file")
|
||||
return@launch
|
||||
}
|
||||
inputStream.use {
|
||||
_restorePreview.value = backupManager.peekBackup(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreBackup(
|
||||
uri: Uri,
|
||||
contentResolver: android.content.ContentResolver,
|
||||
restoreNotifications: Boolean = true,
|
||||
restoreFilterRules: Boolean = true,
|
||||
restoreHiddenApps: Boolean = true,
|
||||
restoreMedia: Boolean = true
|
||||
) {
|
||||
_backupState.value = BackupUiState.Working
|
||||
viewModelScope.launch {
|
||||
val inputStream = contentResolver.openInputStream(uri)
|
||||
if (inputStream == null) {
|
||||
_backupState.value = BackupUiState.Error("Cannot open file")
|
||||
return@launch
|
||||
}
|
||||
inputStream.use {
|
||||
val result = backupManager.restoreBackup(
|
||||
it, restoreNotifications, restoreFilterRules, restoreHiddenApps, restoreMedia
|
||||
)
|
||||
_backupState.value = if (result.success) {
|
||||
refreshStats()
|
||||
BackupUiState.RestoreSuccess(result)
|
||||
} else {
|
||||
BackupUiState.Error(result.error ?: "Restore failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearRestorePreview() {
|
||||
_restorePreview.value = null
|
||||
}
|
||||
|
||||
fun resetState() {
|
||||
_backupState.value = BackupUiState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface BackupUiState {
|
||||
data object Idle : BackupUiState
|
||||
data object Working : BackupUiState
|
||||
data class BackupSuccess(val fileName: String) : BackupUiState
|
||||
data class RestoreSuccess(val result: RestoreResult) : BackupUiState
|
||||
data class Error(val message: String) : BackupUiState
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import androidx.compose.foundation.shape.CircleShape
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -42,9 +42,7 @@ fun AppIcon(
|
|||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var imageBitmap by androidx.compose.runtime.mutableStateOf(loadAppIcon(context, packageName))
|
||||
|
||||
val bitmap = imageBitmap
|
||||
val bitmap = remember(packageName) { loadAppIcon(context, packageName) }
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
|
|
|
|||
|
|
@ -79,17 +79,25 @@ private fun NotificationItemContent(
|
|||
searchQuery: String = ""
|
||||
) {
|
||||
val highlightColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.3f)
|
||||
val isUnread = !notification.isRead
|
||||
|
||||
Row(modifier = Modifier.height(intrinsicSize = androidx.compose.foundation.layout.IntrinsicSize.Min)) {
|
||||
// Left edge: category color for unread, muted for read
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(4.dp)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, bottomStart = 12.dp))
|
||||
.background(categoryColor)
|
||||
.background(if (isUnread) categoryColor else categoryColor.copy(alpha = 0.3f))
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (isUnread) Modifier.background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.10f))
|
||||
else Modifier
|
||||
)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AppIcon(
|
||||
|
|
@ -113,7 +121,7 @@ private fun NotificationItemContent(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
if (!notification.isRead) {
|
||||
if (isUnread) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
|
|
@ -125,14 +133,14 @@ private fun NotificationItemContent(
|
|||
Text(
|
||||
text = notification.appName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = if (!notification.isRead) FontWeight.Bold else FontWeight.SemiBold,
|
||||
fontWeight = if (isUnread) FontWeight.ExtraBold else FontWeight.Normal,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = formatTime(notification.timestamp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = if (isUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -140,7 +148,7 @@ private fun NotificationItemContent(
|
|||
Text(
|
||||
text = highlightMatches(notification.title, searchQuery, highlightColor),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = if (isUnread) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
|
@ -179,13 +187,23 @@ private fun NotificationItemContent(
|
|||
}
|
||||
|
||||
if (notification.isBookmarked) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bookmark,
|
||||
contentDescription = stringResource(R.string.action_bookmarked),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.background(
|
||||
color = Color(0xFFFF8F00),
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bookmark,
|
||||
contentDescription = stringResource(R.string.action_bookmarked),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -227,8 +245,8 @@ private fun highlightMatches(
|
|||
}
|
||||
}
|
||||
|
||||
private val defaultTimeFormat = SimpleDateFormat("h:mm a", Locale.getDefault())
|
||||
private val defaultDateFormat = SimpleDateFormat("MMM d", Locale.getDefault())
|
||||
private val defaultTimeFormat = ThreadLocal.withInitial { SimpleDateFormat("h:mm a", Locale.getDefault()) }
|
||||
private val defaultDateFormat = ThreadLocal.withInitial { SimpleDateFormat("MMM d", Locale.getDefault()) }
|
||||
|
||||
@Composable
|
||||
private fun formatTime(timestamp: Long): String {
|
||||
|
|
@ -237,12 +255,12 @@ private fun formatTime(timestamp: Long): String {
|
|||
val now = System.currentTimeMillis()
|
||||
val diff = now - timestamp
|
||||
val oneDay = 24 * 60 * 60 * 1000L
|
||||
return if (diff < oneDay) defaultTimeFormat.format(Date(timestamp))
|
||||
else defaultDateFormat.format(Date(timestamp))
|
||||
return if (diff < oneDay) defaultTimeFormat.get()!!.format(Date(timestamp))
|
||||
else defaultDateFormat.get()!!.format(Date(timestamp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun formatTimeShort(timestamp: Long): String {
|
||||
val formatter = LocalDateFormatter.current
|
||||
return formatter?.formatTime(timestamp) ?: defaultTimeFormat.format(Date(timestamp))
|
||||
return formatter?.formatTime(timestamp) ?: defaultTimeFormat.get()!!.format(Date(timestamp))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
|
@ -32,6 +34,8 @@ 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.ChevronLeft
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DoneAll
|
||||
|
|
@ -39,6 +43,7 @@ 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.FilterAlt
|
||||
import androidx.compose.material.icons.filled.MarkEmailRead
|
||||
import androidx.compose.material.icons.filled.MarkEmailUnread
|
||||
import androidx.compose.material.icons.filled.NotificationsOff
|
||||
|
|
@ -49,6 +54,7 @@ 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.BottomAppBar
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
|
|
@ -60,10 +66,15 @@ import androidx.compose.material3.IconButton
|
|||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -86,6 +97,8 @@ import com.roundingmobile.sni.instrumentation.testTrack
|
|||
import com.roundingmobile.sni.presentation.common.AppIcon
|
||||
import com.roundingmobile.sni.presentation.common.LocalDateFormatter
|
||||
import com.roundingmobile.sni.presentation.common.NotificationActions
|
||||
import com.roundingmobile.sni.presentation.settings.FilterRuleEditorSheet
|
||||
import com.roundingmobile.sni.presentation.settings.FilterRulesViewModel
|
||||
import com.roundingmobile.sni.service.NotificationCaptureService.ActionInfo
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
|
@ -96,12 +109,55 @@ import java.io.File
|
|||
@Composable
|
||||
fun DetailScreen(
|
||||
onBack: () -> Unit,
|
||||
onNavigateToDetail: (id: Long, undoId: Long) -> Unit = { _, _ -> },
|
||||
instrumentation: TestInstrumentation,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: DetailViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val prevId by viewModel.prevId.collectAsStateWithLifecycle()
|
||||
val nextId by viewModel.nextId.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val deletedMessage = stringResource(R.string.detail_deleted_snackbar)
|
||||
val undoLabel = stringResource(R.string.action_undo)
|
||||
|
||||
// Filter rule creation from detail
|
||||
val filterRulesViewModel: FilterRulesViewModel = hiltViewModel()
|
||||
var showFilterEditor by remember { mutableStateOf(false) }
|
||||
|
||||
// Show undo snackbar if we arrived here after a delete
|
||||
val pendingUndoId = viewModel.pendingUndoId
|
||||
LaunchedEffect(pendingUndoId) {
|
||||
if (pendingUndoId > 0L) {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = deletedMessage,
|
||||
actionLabel = undoLabel,
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
viewModel.undoDelete(pendingUndoId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle navigation events from delete
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.navigationEvent.collect { event ->
|
||||
when (event) {
|
||||
is NavigationEvent.DeletedWithUndo -> {
|
||||
if (event.navigateToId != null) {
|
||||
onNavigateToDetail(event.navigateToId, event.deletedId)
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
is NavigationEvent.NavigateTo -> {
|
||||
onNavigateToDetail(event.id, 0L)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
|
@ -114,6 +170,57 @@ fun DetailScreen(
|
|||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
val hasPrev = prevId != null
|
||||
val hasNext = nextId != null
|
||||
androidx.compose.material3.Surface(
|
||||
tonalElevation = 2.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = { prevId?.let { onNavigateToDetail(it, 0L) } },
|
||||
enabled = hasPrev,
|
||||
colors = androidx.compose.material3.ButtonDefaults.filledTonalButtonColors(
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
|
||||
),
|
||||
modifier = Modifier.weight(1f).height(40.dp)
|
||||
) {
|
||||
Icon(Icons.Default.ChevronLeft, contentDescription = stringResource(R.string.cd_previous_notification))
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.deleteNotification() },
|
||||
colors = androidx.compose.material3.ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
),
|
||||
modifier = Modifier.weight(1f).height(40.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete))
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { nextId?.let { onNavigateToDetail(it, 0L) } },
|
||||
enabled = hasNext,
|
||||
colors = androidx.compose.material3.ButtonDefaults.filledTonalButtonColors(
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
|
||||
),
|
||||
modifier = Modifier.weight(1f).height(40.dp)
|
||||
) {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = stringResource(R.string.cd_next_notification))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
modifier = modifier
|
||||
) { innerPadding ->
|
||||
when (val state = uiState) {
|
||||
|
|
@ -133,12 +240,9 @@ fun DetailScreen(
|
|||
val liveActions by viewModel.liveActions.collectAsStateWithLifecycle()
|
||||
DetailContent(
|
||||
notification = state.notification,
|
||||
onDelete = {
|
||||
viewModel.deleteNotification()
|
||||
onBack()
|
||||
},
|
||||
onToggleBookmark = { viewModel.toggleBookmark() },
|
||||
onToggleRead = { viewModel.toggleRead() },
|
||||
onCreateFilter = { showFilterEditor = true },
|
||||
liveActions = liveActions,
|
||||
onExecuteAction = { index, actionLabel ->
|
||||
val success = viewModel.executeAction(index)
|
||||
|
|
@ -179,15 +283,39 @@ fun DetailScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter rule editor sheet
|
||||
if (showFilterEditor) {
|
||||
val currentNotification = (uiState as? DetailUiState.Success)?.notification
|
||||
if (currentNotification != null) {
|
||||
val ruleNotifApps by filterRulesViewModel.notificationApps.collectAsStateWithLifecycle()
|
||||
val ruleIsPro by filterRulesViewModel.isPro.collectAsStateWithLifecycle()
|
||||
val ruleKnownActions by filterRulesViewModel.knownActions.collectAsStateWithLifecycle()
|
||||
FilterRuleEditorSheet(
|
||||
initialRule = FilterRulesViewModel.prefillFromNotification(currentNotification),
|
||||
notificationApps = ruleNotifApps,
|
||||
isPro = ruleIsPro,
|
||||
knownActions = ruleKnownActions,
|
||||
onAppSelected = { filterRulesViewModel.setSelectedPackage(it) },
|
||||
sourceTitle = currentNotification.title,
|
||||
sourceText = currentNotification.text,
|
||||
onSave = { rule ->
|
||||
filterRulesViewModel.createRule(rule)
|
||||
showFilterEditor = false
|
||||
},
|
||||
onDismiss = { showFilterEditor = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun DetailContent(
|
||||
notification: CapturedNotification,
|
||||
onDelete: () -> Unit,
|
||||
onToggleBookmark: () -> Unit,
|
||||
onToggleRead: () -> Unit,
|
||||
onCreateFilter: () -> Unit,
|
||||
liveActions: List<ActionInfo>,
|
||||
onExecuteAction: (Int, String) -> Unit,
|
||||
onExecuteReplyAction: (Int, String, String) -> Unit,
|
||||
|
|
@ -368,17 +496,13 @@ private fun DetailContent(
|
|||
)
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = onDelete,
|
||||
colors = androidx.compose.material3.ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
),
|
||||
onClick = onCreateFilter,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTrack(instrumentation, "btn_delete", "Delete")
|
||||
.testTrack(instrumentation, "btn_create_filter", "Filter")
|
||||
) {
|
||||
Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
|
||||
Text(stringResource(R.string.action_delete))
|
||||
Icon(Icons.Default.FilterAlt, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
|
||||
Text(stringResource(R.string.action_create_filter_rule), maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -476,8 +600,6 @@ private fun AutoActionBanner(reason: String) {
|
|||
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(" ("))
|
||||
|
|
@ -528,7 +650,6 @@ private fun InfoForNerdsSection(notification: CapturedNotification) {
|
|||
}
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column(modifier = Modifier.padding(start = 8.dp)) {
|
||||
// Metadata
|
||||
NerdRow(stringResource(R.string.detail_category_label), notification.category ?: stringResource(R.string.detail_unknown_category))
|
||||
NerdRow(stringResource(R.string.detail_priority_label), notification.priority.toString())
|
||||
NerdRow(stringResource(R.string.detail_notification_id_label), notification.notificationId.toString())
|
||||
|
|
@ -536,7 +657,6 @@ 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),
|
||||
|
|
@ -544,7 +664,6 @@ private fun InfoForNerdsSection(notification: CapturedNotification) {
|
|||
)
|
||||
}
|
||||
|
||||
// Removal info
|
||||
if (notification.isRemoved) {
|
||||
NerdRow(stringResource(R.string.detail_removed_label), stringResource(R.string.detail_yes))
|
||||
notification.removedAt?.let { removedAt ->
|
||||
|
|
@ -563,7 +682,6 @@ private fun InfoForNerdsSection(notification: CapturedNotification) {
|
|||
}
|
||||
}
|
||||
|
||||
// Extras from notification bundle
|
||||
if (!notification.extrasJson.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
|
|
@ -614,7 +732,6 @@ private fun ReplyDialog(
|
|||
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()
|
||||
}
|
||||
|
|
@ -654,4 +771,3 @@ private fun ReplyDialog(
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,9 +11,12 @@ import com.roundingmobile.sni.presentation.navigation.Route
|
|||
import com.roundingmobile.sni.service.NotificationCaptureService
|
||||
import com.roundingmobile.sni.service.NotificationCaptureService.ActionInfo
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
|
@ -33,6 +36,7 @@ class DetailViewModel @Inject constructor(
|
|||
.map { notification ->
|
||||
if (notification != null) {
|
||||
refreshLiveActions(notification)
|
||||
refreshAdjacentIds(notification.id)
|
||||
DetailUiState.Success(notification)
|
||||
} else {
|
||||
DetailUiState.NotFound
|
||||
|
|
@ -43,12 +47,32 @@ class DetailViewModel @Inject constructor(
|
|||
private val _liveActions = MutableStateFlow<List<ActionInfo>>(emptyList())
|
||||
val liveActions: StateFlow<List<ActionInfo>> = _liveActions.asStateFlow()
|
||||
|
||||
private val _prevId = MutableStateFlow<Long?>(null)
|
||||
val prevId: StateFlow<Long?> = _prevId.asStateFlow()
|
||||
|
||||
private val _nextId = MutableStateFlow<Long?>(null)
|
||||
val nextId: StateFlow<Long?> = _nextId.asStateFlow()
|
||||
|
||||
private val _navigationEvent = MutableSharedFlow<NavigationEvent>()
|
||||
val navigationEvent: SharedFlow<NavigationEvent> = _navigationEvent.asSharedFlow()
|
||||
|
||||
val pendingUndoId: Long = route.undoId
|
||||
|
||||
private var lastDeletedId: Long? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repository.setRead(route.notificationId, true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshAdjacentIds(currentId: Long) {
|
||||
viewModelScope.launch {
|
||||
_prevId.value = repository.getPrevId(currentId)
|
||||
_nextId.value = repository.getNextId(currentId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshLiveActions(notification: CapturedNotification) {
|
||||
val service = NotificationCaptureService.instance ?: run {
|
||||
_liveActions.value = emptyList()
|
||||
|
|
@ -96,29 +120,75 @@ class DetailViewModel @Inject constructor(
|
|||
val state = uiState.value
|
||||
if (state is DetailUiState.Success) {
|
||||
val notification = state.notification
|
||||
NotificationCaptureService.instance?.dismissFromStatusBar(
|
||||
notification.packageName, notification.notificationId, notification.notificationTag
|
||||
)
|
||||
val targetNextId = _nextId.value ?: _prevId.value
|
||||
lastDeletedId = notification.id
|
||||
|
||||
// Try to fire the app's own "Delete" action button if available
|
||||
val service = NotificationCaptureService.instance
|
||||
if (service != null) {
|
||||
val actions = service.getActiveActions(
|
||||
notification.packageName, notification.notificationId, notification.notificationTag
|
||||
)
|
||||
val deleteIndex = actions.indexOfFirst {
|
||||
val lower = it.lowercase()
|
||||
lower.contains("delete") || lower.contains("trash") || lower.contains("remove")
|
||||
}
|
||||
if (deleteIndex >= 0) {
|
||||
service.executeAction(
|
||||
notification.packageName, notification.notificationId,
|
||||
notification.notificationTag, deleteIndex
|
||||
)
|
||||
} else {
|
||||
service.dismissFromStatusBar(
|
||||
notification.packageName, notification.notificationId, notification.notificationTag
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
repository.softDeleteById(notification.id)
|
||||
_navigationEvent.emit(
|
||||
NavigationEvent.DeletedWithUndo(
|
||||
navigateToId = targetNextId,
|
||||
deletedId = notification.id
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun undoDelete(id: Long = 0L) {
|
||||
val targetId = if (id > 0L) id else lastDeletedId ?: return
|
||||
lastDeletedId = null
|
||||
viewModelScope.launch {
|
||||
repository.undoSoftDelete(targetId)
|
||||
_navigationEvent.emit(NavigationEvent.NavigateTo(targetId))
|
||||
}
|
||||
}
|
||||
|
||||
private var bookmarkInFlight = false
|
||||
private var readInFlight = false
|
||||
|
||||
fun toggleBookmark() {
|
||||
if (bookmarkInFlight) return
|
||||
val state = uiState.value
|
||||
if (state is DetailUiState.Success) {
|
||||
bookmarkInFlight = true
|
||||
viewModelScope.launch {
|
||||
repository.setBookmarked(state.notification.id, !state.notification.isBookmarked)
|
||||
bookmarkInFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRead() {
|
||||
if (readInFlight) return
|
||||
val state = uiState.value
|
||||
if (state is DetailUiState.Success) {
|
||||
readInFlight = true
|
||||
viewModelScope.launch {
|
||||
repository.setRead(state.notification.id, !state.notification.isRead)
|
||||
readInFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -129,3 +199,8 @@ sealed interface DetailUiState {
|
|||
data object NotFound : DetailUiState
|
||||
data class Success(val notification: CapturedNotification) : DetailUiState
|
||||
}
|
||||
|
||||
sealed interface NavigationEvent {
|
||||
data class DeletedWithUndo(val navigateToId: Long?, val deletedId: Long) : NavigationEvent
|
||||
data class NavigateTo(val id: Long) : NavigationEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,9 +70,12 @@ fun LockScreen(
|
|||
shakeOffset.animateTo(0f)
|
||||
}
|
||||
delay(200)
|
||||
// Clear pin and schedule error reset in scope so it survives recomposition
|
||||
pin = ""
|
||||
delay(1000)
|
||||
error = false
|
||||
scope.launch {
|
||||
delay(1000)
|
||||
error = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import androidx.navigation.compose.NavHost
|
|||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import com.roundingmobile.sni.instrumentation.TestInstrumentation
|
||||
import com.roundingmobile.sni.presentation.backup.BackupScreen
|
||||
import com.roundingmobile.sni.presentation.trash.TrashScreen
|
||||
import com.roundingmobile.sni.presentation.detail.DetailScreen
|
||||
import com.roundingmobile.sni.presentation.export.ExportScreen
|
||||
import com.roundingmobile.sni.presentation.settings.FilterRulesScreen
|
||||
|
|
@ -47,9 +49,20 @@ fun AppNavGraph(
|
|||
instrumentation = instrumentation,
|
||||
onNavigateToFilterRules = { navController.navigate(Route.FilterRules) },
|
||||
onNavigateToMediaStorage = { navController.navigate(Route.MediaStorage) },
|
||||
onNavigateToBackup = { navController.navigate(Route.Backup) },
|
||||
onNavigateToTrash = { navController.navigate(Route.Trash) },
|
||||
onRequestBiometric = onRequestBiometric
|
||||
)
|
||||
}
|
||||
composable<Route.Backup> {
|
||||
BackupScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable<Route.Trash> {
|
||||
TrashScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onNotificationClick = { id -> navController.navigate(Route.Detail(id)) }
|
||||
)
|
||||
}
|
||||
composable<Route.MediaStorage> {
|
||||
MediaStorageScreen(
|
||||
onBack = { navController.popBackStack() }
|
||||
|
|
@ -64,6 +77,11 @@ fun AppNavGraph(
|
|||
composable<Route.Detail> {
|
||||
DetailScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onNavigateToDetail = { id, undoId ->
|
||||
navController.navigate(Route.Detail(id, undoId)) {
|
||||
popUpTo<Route.Detail> { inclusive = true }
|
||||
}
|
||||
},
|
||||
instrumentation = instrumentation
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ sealed interface Route {
|
|||
@Serializable data object Timeline : Route
|
||||
@Serializable data object Stats : Route
|
||||
@Serializable data object Settings : Route
|
||||
@Serializable data class Detail(val notificationId: Long) : Route
|
||||
@Serializable data class Detail(val notificationId: Long, val undoId: Long = 0L) : Route
|
||||
@Serializable data object Export : Route
|
||||
@Serializable data object FilterRules : Route
|
||||
@Serializable data class Conversation(val packageName: String, val sender: String) : Route
|
||||
@Serializable data object MediaStorage : Route
|
||||
@Serializable data object Backup : Route
|
||||
@Serializable data object Trash : Route
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import androidx.compose.animation.AnimatedVisibility
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -71,6 +73,10 @@ fun FilterRuleEditorSheet(
|
|||
initialRule: FilterRule,
|
||||
notificationApps: List<AppInfo>,
|
||||
isPro: Boolean,
|
||||
knownActions: List<String> = emptyList(),
|
||||
onAppSelected: (String?) -> Unit = {},
|
||||
sourceTitle: String? = null,
|
||||
sourceText: String? = null,
|
||||
onSave: (FilterRule) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
|
|
@ -108,6 +114,7 @@ fun FilterRuleEditorSheet(
|
|||
}
|
||||
|
||||
val hasPattern = conditions.any { it.pattern.isNotBlank() }
|
||||
val canSave = hasPattern || selectedPackage != null
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
|
|
@ -189,7 +196,9 @@ fun FilterRuleEditorSheet(
|
|||
condition = condition,
|
||||
showDelete = conditions.size > 1,
|
||||
onDelete = { conditions.removeAt(index) },
|
||||
onUpdate = { conditions[index] = it }
|
||||
onUpdate = { conditions[index] = it },
|
||||
sourceTitle = sourceTitle,
|
||||
sourceText = sourceText
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -247,7 +256,12 @@ fun FilterRuleEditorSheet(
|
|||
5 to stringResource(R.string.filter_cooldown_5m),
|
||||
15 to stringResource(R.string.filter_cooldown_15m),
|
||||
30 to stringResource(R.string.filter_cooldown_30m),
|
||||
60 to stringResource(R.string.filter_cooldown_1h)
|
||||
60 to stringResource(R.string.filter_cooldown_1h),
|
||||
120 to stringResource(R.string.filter_cooldown_2h),
|
||||
300 to stringResource(R.string.filter_cooldown_5h),
|
||||
480 to stringResource(R.string.filter_cooldown_8h),
|
||||
720 to stringResource(R.string.filter_cooldown_12h),
|
||||
1440 to stringResource(R.string.filter_cooldown_24h)
|
||||
),
|
||||
onSelect = { cooldownMinutes = it }
|
||||
)
|
||||
|
|
@ -255,9 +269,42 @@ fun FilterRuleEditorSheet(
|
|||
}
|
||||
|
||||
// Execute action params
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
AnimatedVisibility(visible = action == FilterAction.EXECUTE_ACTION) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SentenceLabel(stringResource(R.string.filter_sentence_the_button_labeled))
|
||||
|
||||
// Known action buttons for the selected app
|
||||
if (knownActions.isNotEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.filter_execute_known_actions),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
knownActions.forEach { actionLabel ->
|
||||
SuggestionChip(
|
||||
onClick = { executeActionLabel = actionLabel },
|
||||
label = { Text(actionLabel, style = MaterialTheme.typography.bodySmall) },
|
||||
colors = SuggestionChipDefaults.suggestionChipColors(
|
||||
containerColor = if (executeActionLabel.equals(actionLabel, ignoreCase = true))
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.filter_execute_actions_note),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = executeActionLabel,
|
||||
onValueChange = { executeActionLabel = it },
|
||||
|
|
@ -309,7 +356,7 @@ fun FilterRuleEditorSheet(
|
|||
}
|
||||
|
||||
// Live preview
|
||||
AnimatedVisibility(visible = hasPattern) {
|
||||
AnimatedVisibility(visible = canSave) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
||||
|
|
@ -343,8 +390,13 @@ fun FilterRuleEditorSheet(
|
|||
.filter { it.pattern.isNotBlank() }
|
||||
.map { FilterCondition.of(it.matchField, it.matchType, it.pattern) }
|
||||
val params = when (action) {
|
||||
FilterAction.COOLDOWN -> """{"duration_ms":${cooldownMinutes.toLong() * 60_000}}"""
|
||||
FilterAction.EXECUTE_ACTION -> """{"action_label":"${executeActionLabel.replace("\"", "\\\"")}","also_delete":$executeAlsoDelete}"""
|
||||
FilterAction.COOLDOWN -> kotlinx.serialization.json.buildJsonObject {
|
||||
put("duration_ms", kotlinx.serialization.json.JsonPrimitive(cooldownMinutes.toLong() * 60_000))
|
||||
}.toString()
|
||||
FilterAction.EXECUTE_ACTION -> kotlinx.serialization.json.buildJsonObject {
|
||||
put("action_label", kotlinx.serialization.json.JsonPrimitive(executeActionLabel))
|
||||
put("also_delete", kotlinx.serialization.json.JsonPrimitive(executeAlsoDelete))
|
||||
}.toString()
|
||||
else -> null
|
||||
}
|
||||
val res = context.resources
|
||||
|
|
@ -365,7 +417,7 @@ fun FilterRuleEditorSheet(
|
|||
)
|
||||
)
|
||||
},
|
||||
enabled = hasPattern
|
||||
enabled = canSave
|
||||
) { Text(stringResource(R.string.action_save)) }
|
||||
}
|
||||
|
||||
|
|
@ -373,6 +425,11 @@ fun FilterRuleEditorSheet(
|
|||
}
|
||||
}
|
||||
|
||||
// Notify parent of initial selected package so it can load known actions
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
onAppSelected(selectedPackage)
|
||||
}
|
||||
|
||||
if (showAppPicker) {
|
||||
AppPickerSheet(
|
||||
notificationApps = notificationApps,
|
||||
|
|
@ -380,6 +437,7 @@ fun FilterRuleEditorSheet(
|
|||
onSelectApp = { pkg, appName ->
|
||||
selectedPackage = pkg
|
||||
selectedAppName = appName
|
||||
onAppSelected(pkg)
|
||||
showAppPicker = false
|
||||
},
|
||||
onDismiss = { showAppPicker = false }
|
||||
|
|
@ -401,8 +459,26 @@ private fun ConditionRow(
|
|||
condition: EditableCondition,
|
||||
showDelete: Boolean,
|
||||
onDelete: () -> Unit,
|
||||
onUpdate: (EditableCondition) -> Unit
|
||||
onUpdate: (EditableCondition) -> Unit,
|
||||
sourceTitle: String? = null,
|
||||
sourceText: String? = null
|
||||
) {
|
||||
// Helper to get a shortened source value for a given field
|
||||
val sentenceEnd = Regex("""[.!?]\s""")
|
||||
fun sourceValueFor(field: MatchField): String? {
|
||||
val raw = when (field) {
|
||||
MatchField.TITLE -> sourceTitle
|
||||
MatchField.TEXT -> sourceText
|
||||
MatchField.TITLE_OR_TEXT -> sourceTitle ?: sourceText
|
||||
else -> null
|
||||
} ?: return null
|
||||
if (raw.length <= 60) return raw
|
||||
val match = sentenceEnd.find(raw)
|
||||
if (match != null && match.range.first in 1 until 60) return raw.substring(0, match.range.first + 1)
|
||||
val cutPoint = raw.lastIndexOf(' ', 60)
|
||||
return if (cutPoint > 20) raw.substring(0, cutPoint) else raw.substring(0, 60)
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
|
@ -416,7 +492,15 @@ private fun ConditionRow(
|
|||
MatchField.TITLE_OR_TEXT to stringResource(R.string.filter_field_title_or_text),
|
||||
MatchField.APP_NAME to stringResource(R.string.filter_field_app_name)
|
||||
),
|
||||
onSelect = { onUpdate(condition.copy(matchField = it)) }
|
||||
onSelect = { newField ->
|
||||
val autoFill = sourceValueFor(newField)
|
||||
val oldSource = sourceValueFor(condition.matchField)
|
||||
// Auto-fill if pattern is empty or still matches the previous source value
|
||||
val newPattern = if (autoFill != null && (condition.pattern.isBlank() || condition.pattern == oldSource)) {
|
||||
autoFill
|
||||
} else condition.pattern
|
||||
onUpdate(condition.copy(matchField = newField, pattern = newPattern))
|
||||
}
|
||||
)
|
||||
SentenceDropdown(
|
||||
selected = condition.matchType,
|
||||
|
|
@ -437,11 +521,18 @@ private fun ConditionRow(
|
|||
}
|
||||
}
|
||||
}
|
||||
val placeholderRes = when (condition.matchField) {
|
||||
MatchField.TITLE -> R.string.filter_sentence_enter_title
|
||||
MatchField.APP_NAME -> R.string.filter_sentence_enter_app_name
|
||||
MatchField.PACKAGE_NAME -> R.string.filter_sentence_enter_package
|
||||
else -> R.string.filter_sentence_enter_text
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = condition.pattern,
|
||||
onValueChange = { onUpdate(condition.copy(pattern = it)) },
|
||||
placeholder = { Text(stringResource(R.string.filter_sentence_enter_text)) },
|
||||
singleLine = true,
|
||||
placeholder = { Text(stringResource(placeholderRes)) },
|
||||
singleLine = false,
|
||||
maxLines = 3,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
|
@ -502,7 +593,7 @@ private fun parseCooldownMinutes(params: String?): Int {
|
|||
val obj = kotlinx.serialization.json.Json.parseToJsonElement(params).asJsonObjectOrNull()
|
||||
if (obj == null) return 2
|
||||
val ms = obj["duration_ms"]?.asJsonPrimitiveOrNull()?.content?.toLongOrNull() ?: 120_000L
|
||||
(ms / 60_000).toInt().coerceIn(1, 60)
|
||||
(ms / 60_000).toInt().coerceIn(1, 1440)
|
||||
} catch (_: Exception) { 2 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ fun FilterRulesScreen(
|
|||
val rules by viewModel.rules.collectAsStateWithLifecycle()
|
||||
val notificationApps by viewModel.notificationApps.collectAsStateWithLifecycle()
|
||||
val isPro by viewModel.isPro.collectAsStateWithLifecycle()
|
||||
val knownActions by viewModel.knownActions.collectAsStateWithLifecycle()
|
||||
var showEditor by remember { mutableStateOf(false) }
|
||||
var editingRule by remember { mutableStateOf<FilterRule?>(null) }
|
||||
|
||||
|
|
@ -148,6 +149,8 @@ fun FilterRulesScreen(
|
|||
),
|
||||
notificationApps = notificationApps,
|
||||
isPro = isPro,
|
||||
knownActions = knownActions,
|
||||
onAppSelected = { viewModel.setSelectedPackage(it) },
|
||||
onSave = { rule ->
|
||||
viewModel.createRule(rule)
|
||||
showEditor = false
|
||||
|
|
@ -161,6 +164,8 @@ fun FilterRulesScreen(
|
|||
initialRule = rule,
|
||||
notificationApps = notificationApps,
|
||||
isPro = isPro,
|
||||
knownActions = knownActions,
|
||||
onAppSelected = { viewModel.setSelectedPackage(it) },
|
||||
onSave = { updated ->
|
||||
viewModel.updateRule(updated)
|
||||
editingRule = null
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.roundingmobile.sni.presentation.settings
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.roundingmobile.sni.data.local.db.dao.AppActionDao
|
||||
import com.roundingmobile.sni.domain.model.AppInfo
|
||||
import com.roundingmobile.sni.domain.model.CapturedNotification
|
||||
import com.roundingmobile.sni.domain.model.FilterAction
|
||||
|
|
@ -13,8 +14,13 @@ import com.roundingmobile.sni.domain.provider.ProStatusProvider
|
|||
import com.roundingmobile.sni.domain.repository.FilterRuleRepository
|
||||
import com.roundingmobile.sni.domain.repository.NotificationRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
|
@ -23,7 +29,8 @@ import javax.inject.Inject
|
|||
class FilterRulesViewModel @Inject constructor(
|
||||
private val repository: FilterRuleRepository,
|
||||
private val notificationRepository: NotificationRepository,
|
||||
private val proStatusProvider: ProStatusProvider
|
||||
private val proStatusProvider: ProStatusProvider,
|
||||
private val appActionDao: AppActionDao
|
||||
) : ViewModel() {
|
||||
|
||||
val rules: StateFlow<List<FilterRule>> = repository.getAllRulesFlow()
|
||||
|
|
@ -35,6 +42,22 @@ class FilterRulesViewModel @Inject constructor(
|
|||
val isPro: StateFlow<Boolean> = proStatusProvider.isProFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), proStatusProvider.isPro)
|
||||
|
||||
private val _selectedPackage = MutableStateFlow<String?>(null)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val knownActions: StateFlow<List<String>> = _selectedPackage
|
||||
.flatMapLatest { pkg ->
|
||||
if (pkg == null) flowOf(emptyList())
|
||||
else appActionDao.getNonReplyActionsFlow(pkg).map { entities ->
|
||||
entities.map { it.label }
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
fun setSelectedPackage(packageName: String?) {
|
||||
_selectedPackage.value = packageName
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch { repository.seedPresetsIfNeeded() }
|
||||
}
|
||||
|
|
@ -57,6 +80,22 @@ class FilterRulesViewModel @Inject constructor(
|
|||
|
||||
companion object {
|
||||
private val SUMMARY_PATTERN = Regex("\\d+\\s+(new\\s+)?(messages?|notifications?|emails?|chats?|updates?)")
|
||||
private const val MAX_PATTERN_LENGTH = 60
|
||||
|
||||
/** Shorten a long text to a usable filter pattern — first sentence or first N chars */
|
||||
private val SENTENCE_END = Regex("""[.!?]\s""")
|
||||
|
||||
private fun shortenForPattern(text: String): String {
|
||||
if (text.length <= MAX_PATTERN_LENGTH) return text
|
||||
// Try to cut at first sentence boundary (period/!/? followed by space — avoids URLs)
|
||||
val match = SENTENCE_END.find(text)
|
||||
if (match != null && match.range.first in 1 until MAX_PATTERN_LENGTH) {
|
||||
return text.substring(0, match.range.first + 1)
|
||||
}
|
||||
// Cut at last word boundary before max length
|
||||
val cutPoint = text.lastIndexOf(' ', MAX_PATTERN_LENGTH)
|
||||
return if (cutPoint > 20) text.substring(0, cutPoint) else text.substring(0, MAX_PATTERN_LENGTH)
|
||||
}
|
||||
|
||||
fun prefillFromNotification(notification: CapturedNotification): FilterRule {
|
||||
val title = notification.title
|
||||
|
|
@ -68,16 +107,16 @@ class FilterRulesViewModel @Inject constructor(
|
|||
val generalized = SUMMARY_PATTERN.replace(text) { match ->
|
||||
match.value.replace(Regex("^\\d+"), "\\\\d+")
|
||||
}
|
||||
generalized to MatchField.TEXT
|
||||
shortenForPattern(generalized) to MatchField.TEXT
|
||||
}
|
||||
title != null && title.equals(appName, ignoreCase = true) -> {
|
||||
(text ?: "") to MatchField.TEXT
|
||||
shortenForPattern(text ?: "") to MatchField.TEXT
|
||||
}
|
||||
title != null && title.isNotBlank() -> {
|
||||
title to MatchField.TITLE
|
||||
}
|
||||
else -> {
|
||||
(text ?: "") to MatchField.TITLE_OR_TEXT
|
||||
shortenForPattern(text ?: "") to MatchField.TITLE_OR_TEXT
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +124,7 @@ class FilterRulesViewModel @Inject constructor(
|
|||
else MatchType.CONTAINS
|
||||
|
||||
return FilterRule(
|
||||
name = "Filter ${title ?: appName}",
|
||||
name = "",
|
||||
action = FilterAction.DONT_SAVE,
|
||||
conditions = listOf(FilterCondition.of(field, matchType, pattern)),
|
||||
packageName = notification.packageName,
|
||||
|
|
|
|||
|
|
@ -47,10 +47,12 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.roundingmobile.sni.R
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -115,24 +117,31 @@ class MediaStorageViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun refreshStorageStats(mediaDir: File) {
|
||||
val files = mediaDir.listFiles() ?: emptyArray()
|
||||
_state.value = _state.value.copy(
|
||||
fileCount = files.size,
|
||||
totalSizeBytes = files.sumOf { it.length() }
|
||||
)
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
val files = mediaDir.listFiles() ?: emptyArray()
|
||||
_state.value = _state.value.copy(
|
||||
fileCount = files.size,
|
||||
totalSizeBytes = files.sumOf { it.length() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMediaOlderThan(mediaDir: File, days: Int) {
|
||||
if (days == 0) {
|
||||
// Clear all
|
||||
mediaDir.listFiles()?.forEach { it.delete() }
|
||||
} else {
|
||||
val cutoff = System.currentTimeMillis() - (days.toLong() * 24 * 60 * 60 * 1000)
|
||||
mediaDir.listFiles()?.forEach { file ->
|
||||
if (file.lastModified() < cutoff) file.delete()
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
if (days == 0) {
|
||||
mediaDir.listFiles()?.forEach { it.delete() }
|
||||
} else {
|
||||
val cutoff = System.currentTimeMillis() - (days.toLong() * 24 * 60 * 60 * 1000)
|
||||
mediaDir.listFiles()?.forEach { file ->
|
||||
if (file.lastModified() < cutoff) file.delete()
|
||||
}
|
||||
}
|
||||
val files = mediaDir.listFiles() ?: emptyArray()
|
||||
_state.value = _state.value.copy(
|
||||
fileCount = files.size,
|
||||
totalSizeBytes = files.sumOf { it.length() }
|
||||
)
|
||||
}
|
||||
refreshStorageStats(mediaDir)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -343,8 +352,6 @@ private fun ClearMediaDialog(
|
|||
// Always add "All" at the end
|
||||
data class ClearOption(val days: Int, val label: String)
|
||||
|
||||
val options = mutableListOf<ClearOption>()
|
||||
|
||||
@Composable
|
||||
fun buildOptions(): List<ClearOption> {
|
||||
val result = mutableListOf<ClearOption>()
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import androidx.compose.foundation.shape.CircleShape
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Backup
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.PhotoLibrary
|
||||
import androidx.compose.material.icons.filled.FilterAlt
|
||||
|
|
@ -85,6 +87,8 @@ fun SettingsScreen(
|
|||
instrumentation: TestInstrumentation,
|
||||
onNavigateToFilterRules: () -> Unit = {},
|
||||
onNavigateToMediaStorage: () -> Unit = {},
|
||||
onNavigateToBackup: () -> Unit = {},
|
||||
onNavigateToTrash: () -> Unit = {},
|
||||
onRequestBiometric: (onSuccess: () -> Unit) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
|
|
@ -93,6 +97,7 @@ fun SettingsScreen(
|
|||
val hiddenApps by viewModel.hiddenAppsState.collectAsStateWithLifecycle()
|
||||
val clearDone by viewModel.clearDone.collectAsStateWithLifecycle()
|
||||
val activeRuleCount by viewModel.activeRuleCount.collectAsStateWithLifecycle()
|
||||
val deletedCount by viewModel.deletedCount.collectAsStateWithLifecycle()
|
||||
val isPro by viewModel.isPro.collectAsStateWithLifecycle()
|
||||
|
||||
var showRetentionDialog by remember { mutableStateOf(false) }
|
||||
|
|
@ -387,6 +392,23 @@ fun SettingsScreen(
|
|||
modifier = Modifier.testTrack(instrumentation, "settings_media_storage")
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Backup,
|
||||
title = stringResource(R.string.settings_backup),
|
||||
subtitle = stringResource(R.string.settings_backup_subtitle),
|
||||
onClick = { onNavigateToBackup() },
|
||||
modifier = Modifier.testTrack(instrumentation, "settings_backup")
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Delete,
|
||||
title = stringResource(R.string.settings_trash),
|
||||
subtitle = if (deletedCount > 0) stringResource(R.string.settings_trash_count, deletedCount)
|
||||
else stringResource(R.string.settings_trash_empty),
|
||||
onClick = onNavigateToTrash,
|
||||
modifier = Modifier.testTrack(instrumentation, "settings_trash")
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
SettingsRow(
|
||||
icon = Icons.Default.DeleteSweep,
|
||||
title = stringResource(R.string.settings_clear_all_data),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.roundingmobile.sni.util.AppIconStyle
|
|||
import com.roundingmobile.sni.util.DateFormatOption
|
||||
import com.roundingmobile.sni.util.TimeFormatOption
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -49,6 +51,9 @@ class SettingsViewModel @Inject constructor(
|
|||
val activeRuleCount: StateFlow<Int> = filterRuleRepository.getEnabledCountFlow()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
val deletedCount: StateFlow<Int> = repository.getDeletedCountFlow()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
val isPro: StateFlow<Boolean> = proStatusProvider.isProFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), proStatusProvider.isPro)
|
||||
|
||||
|
|
@ -130,9 +135,11 @@ class SettingsViewModel @Inject constructor(
|
|||
repository.deleteOlderThan(cutoff)
|
||||
repository.purgeDeletedOlderThan(cutoff)
|
||||
// Clean media older than retention
|
||||
val mediaDir = java.io.File(app.filesDir, "media")
|
||||
mediaDir.listFiles()?.forEach { file ->
|
||||
if (file.lastModified() < cutoff) file.delete()
|
||||
withContext(Dispatchers.IO) {
|
||||
val mediaDir = java.io.File(app.filesDir, "media")
|
||||
mediaDir.listFiles()?.forEach { file ->
|
||||
if (file.lastModified() < cutoff) file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -184,8 +191,9 @@ class SettingsViewModel @Inject constructor(
|
|||
fun clearAllData() {
|
||||
viewModelScope.launch {
|
||||
repository.deleteAll()
|
||||
// Also clear media files
|
||||
java.io.File(app.filesDir, "media").listFiles()?.forEach { it.delete() }
|
||||
withContext(Dispatchers.IO) {
|
||||
java.io.File(app.filesDir, "media").listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
_clearDone.value = true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
package com.roundingmobile.sni.presentation.theme
|
||||
|
|
@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
|
|
@ -177,12 +178,35 @@ internal fun ConsecutiveAppGroupHeader(
|
|||
Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
if (isExpanded) Icons.Default.Close else Icons.Default.FilterList,
|
||||
contentDescription = stringResource(if (isExpanded) R.string.cd_collapse else R.string.cd_expand),
|
||||
modifier = Modifier.padding(start = 4.dp).size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
) {
|
||||
if (group.hasBookmark) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(22.dp)
|
||||
.background(
|
||||
color = androidx.compose.ui.graphics.Color(0xFFFF8F00),
|
||||
shape = RoundedCornerShape(5.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Bookmark,
|
||||
contentDescription = stringResource(R.string.action_bookmarked),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = androidx.compose.ui.graphics.Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
if (isExpanded) Icons.Default.Close else Icons.Default.FilterList,
|
||||
contentDescription = stringResource(if (isExpanded) R.string.cd_collapse else R.string.cd_expand),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -335,10 +335,13 @@ fun TimelineScreen(
|
|||
ruleCreationNotification?.let { notification ->
|
||||
val ruleNotifApps by filterRulesViewModel.notificationApps.collectAsStateWithLifecycle()
|
||||
val ruleIsPro by filterRulesViewModel.isPro.collectAsStateWithLifecycle()
|
||||
val ruleKnownActions by filterRulesViewModel.knownActions.collectAsStateWithLifecycle()
|
||||
FilterRuleEditorSheet(
|
||||
initialRule = FilterRulesViewModel.prefillFromNotification(notification),
|
||||
notificationApps = ruleNotifApps,
|
||||
isPro = ruleIsPro,
|
||||
knownActions = ruleKnownActions,
|
||||
onAppSelected = { filterRulesViewModel.setSelectedPackage(it) },
|
||||
onSave = { rule ->
|
||||
filterRulesViewModel.createRule(rule)
|
||||
ruleCreationNotification = null
|
||||
|
|
@ -602,7 +605,6 @@ private fun DayHeader(
|
|||
"Today" -> stringResource(R.string.timeline_today)
|
||||
"Yesterday" -> stringResource(R.string.timeline_yesterday)
|
||||
else -> {
|
||||
val cal = java.util.Calendar.getInstance().apply { timeInMillis = timestamp }
|
||||
val dayName = java.text.SimpleDateFormat("EEEE", java.util.Locale.getDefault()).format(java.util.Date(timestamp))
|
||||
val date = java.text.SimpleDateFormat("d MMMM", java.util.Locale.getDefault()).format(java.util.Date(timestamp))
|
||||
"$dayName $date"
|
||||
|
|
|
|||
|
|
@ -72,9 +72,9 @@ class TimelineViewModel @Inject constructor(
|
|||
private val _filterSheetVisible = MutableStateFlow(false)
|
||||
val filterSheetVisible: StateFlow<Boolean> = _filterSheetVisible.asStateFlow()
|
||||
|
||||
// --- App info cache ---
|
||||
private val appNameCache = mutableMapOf<String, String>()
|
||||
private val appIconCache = mutableMapOf<String, Drawable?>()
|
||||
// --- App info cache (synchronized — ConcurrentHashMap rejects null values) ---
|
||||
private val appNameCache = java.util.Collections.synchronizedMap(mutableMapOf<String, String>())
|
||||
private val appIconCache = java.util.Collections.synchronizedMap(mutableMapOf<String, Drawable?>())
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
|
|
@ -262,7 +262,12 @@ class TimelineViewModel @Inject constructor(
|
|||
return appNameCache.getOrPut(packageName) {
|
||||
try {
|
||||
val pm = context.packageManager
|
||||
val appInfo = pm.getApplicationInfo(packageName, 0)
|
||||
val appInfo = if (android.os.Build.VERSION.SDK_INT >= 33) {
|
||||
pm.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pm.getApplicationInfo(packageName, 0)
|
||||
}
|
||||
pm.getApplicationLabel(appInfo).toString()
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
packageName.substringAfterLast('.').replaceFirstChar { it.uppercase() }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,377 @@
|
|||
package com.roundingmobile.sni.presentation.trash
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.RestoreFromTrash
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.roundingmobile.sni.R
|
||||
import com.roundingmobile.sni.domain.model.CapturedNotification
|
||||
import com.roundingmobile.sni.presentation.common.AppIcon
|
||||
import com.roundingmobile.sni.presentation.common.formatTimeShort
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TrashScreen(
|
||||
onBack: () -> Unit,
|
||||
onNotificationClick: (Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TrashViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val lastAction by viewModel.lastAction.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
var showEmptyConfirm by remember { mutableStateOf(false) }
|
||||
var showRestoreAllConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(lastAction) {
|
||||
val action = lastAction ?: return@LaunchedEffect
|
||||
val message = when (action) {
|
||||
is TrashAction.Restored -> context.getString(R.string.trash_restored, action.count)
|
||||
is TrashAction.PermanentlyDeleted -> context.getString(R.string.trash_permanently_deleted)
|
||||
TrashAction.RestoredAll -> context.getString(R.string.trash_all_restored)
|
||||
TrashAction.EmptiedTrash -> context.getString(R.string.trash_emptied)
|
||||
}
|
||||
snackbarHostState.showSnackbar(message)
|
||||
viewModel.clearAction()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.trash_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.cd_back)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
val hasItems = uiState is TrashUiState.Success
|
||||
AnimatedVisibility(visible = hasItems) {
|
||||
Row {
|
||||
IconButton(onClick = { showRestoreAllConfirm = true }) {
|
||||
Icon(
|
||||
Icons.Default.RestoreFromTrash,
|
||||
contentDescription = stringResource(R.string.trash_restore_all)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showEmptyConfirm = true }) {
|
||||
Icon(
|
||||
Icons.Default.DeleteForever,
|
||||
contentDescription = stringResource(R.string.trash_empty),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
modifier = modifier
|
||||
) { padding ->
|
||||
when (val state = uiState) {
|
||||
TrashUiState.Loading -> {
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
androidx.compose.material3.CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
TrashUiState.Empty -> {
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Default.DeleteSweep,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
stringResource(R.string.trash_empty_state),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.trash_empty_state_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is TrashUiState.Success -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
start = 12.dp, end = 12.dp, top = 8.dp, bottom = 80.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.trash_count, state.notifications.size),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
items(state.notifications, key = { it.id }) { notification ->
|
||||
DeletedNotificationItem(
|
||||
notification = notification,
|
||||
onClick = { onNotificationClick(notification.id) },
|
||||
onRestore = { viewModel.restoreItem(notification.id) },
|
||||
onDeleteForever = { viewModel.permanentlyDelete(notification.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showEmptyConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showEmptyConfirm = false },
|
||||
title = { Text(stringResource(R.string.trash_empty_confirm_title)) },
|
||||
text = { Text(stringResource(R.string.trash_empty_confirm_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.emptyTrash()
|
||||
showEmptyConfirm = false
|
||||
}) {
|
||||
Text(
|
||||
stringResource(R.string.trash_empty_confirm_button),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showEmptyConfirm = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showRestoreAllConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRestoreAllConfirm = false },
|
||||
title = { Text(stringResource(R.string.trash_restore_all_title)) },
|
||||
text = { Text(stringResource(R.string.trash_restore_all_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.restoreAll()
|
||||
showRestoreAllConfirm = false
|
||||
}) {
|
||||
Text(stringResource(R.string.trash_restore_all_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRestoreAllConfirm = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeletedNotificationItem(
|
||||
notification: CapturedNotification,
|
||||
onClick: () -> Unit,
|
||||
onRestore: () -> Unit,
|
||||
onDeleteForever: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val deletionLabel = formatDeletionReason(notification.deletionReason)
|
||||
val timeDeleted = notification.deletedAt?.let { formatTimeShort(it) } ?: ""
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(0.75f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f)
|
||||
),
|
||||
onClick = onClick
|
||||
) {
|
||||
Row(modifier = Modifier.height(intrinsicSize = androidx.compose.foundation.layout.IntrinsicSize.Min)) {
|
||||
// Red left edge to distinguish from normal items
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(4.dp)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, bottomStart = 12.dp))
|
||||
.background(MaterialTheme.colorScheme.error.copy(alpha = 0.6f))
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AppIcon(
|
||||
packageName = notification.packageName,
|
||||
appName = notification.appName,
|
||||
size = 36.dp
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = notification.appName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontStyle = FontStyle.Italic
|
||||
)
|
||||
Text(
|
||||
text = timeDeleted,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
if (!notification.title.isNullOrBlank()) {
|
||||
Text(
|
||||
text = notification.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textDecoration = TextDecoration.LineThrough,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
if (!notification.text.isNullOrBlank()) {
|
||||
Text(
|
||||
text = notification.text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textDecoration = TextDecoration.LineThrough,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
// Deletion reason + action buttons row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Reason chip
|
||||
Text(
|
||||
text = deletionLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f),
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
MaterialTheme.colorScheme.error.copy(alpha = 0.08f),
|
||||
RoundedCornerShape(4.dp)
|
||||
)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||
IconButton(onClick = onRestore, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Default.RestoreFromTrash,
|
||||
contentDescription = stringResource(R.string.trash_restore),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDeleteForever, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Default.DeleteForever,
|
||||
contentDescription = stringResource(R.string.trash_delete_forever),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun formatDeletionReason(reason: String?): String {
|
||||
if (reason == null) return stringResource(R.string.trash_reason_unknown)
|
||||
return when {
|
||||
reason == "USER" -> stringResource(R.string.detail_deletion_reason_user)
|
||||
reason == "USER_BULK" -> stringResource(R.string.detail_deletion_reason_user_bulk)
|
||||
reason.startsWith("AUTO_ACTION:") -> {
|
||||
val label = reason.substringAfter("AUTO_ACTION:").substringBefore(" (rule:")
|
||||
stringResource(R.string.trash_reason_auto_action, label)
|
||||
}
|
||||
reason.startsWith("RETENTION") -> stringResource(R.string.detail_deletion_reason_retention)
|
||||
else -> stringResource(R.string.trash_reason_unknown)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package com.roundingmobile.sni.presentation.trash
|
||||
|
||||
import com.roundingmobile.sni.domain.model.CapturedNotification
|
||||
import com.roundingmobile.sni.domain.repository.NotificationRepository
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class TrashViewModel @Inject constructor(
|
||||
private val repository: NotificationRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<TrashUiState> = repository.getDeletedNotifications()
|
||||
.map { list ->
|
||||
if (list.isEmpty()) TrashUiState.Empty
|
||||
else TrashUiState.Success(list)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrashUiState.Loading)
|
||||
|
||||
private val _lastAction = MutableStateFlow<TrashAction?>(null)
|
||||
val lastAction: StateFlow<TrashAction?> = _lastAction.asStateFlow()
|
||||
|
||||
fun restoreItem(id: Long) {
|
||||
viewModelScope.launch {
|
||||
repository.undoSoftDelete(id)
|
||||
_lastAction.value = TrashAction.Restored(1)
|
||||
}
|
||||
}
|
||||
|
||||
fun permanentlyDelete(id: Long) {
|
||||
viewModelScope.launch {
|
||||
repository.permanentlyDeleteById(id)
|
||||
_lastAction.value = TrashAction.PermanentlyDeleted(1)
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreAll() {
|
||||
viewModelScope.launch {
|
||||
repository.restoreAllDeleted()
|
||||
_lastAction.value = TrashAction.RestoredAll
|
||||
}
|
||||
}
|
||||
|
||||
fun emptyTrash() {
|
||||
viewModelScope.launch {
|
||||
repository.permanentlyDeleteAllDeleted()
|
||||
_lastAction.value = TrashAction.EmptiedTrash
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAction() {
|
||||
_lastAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface TrashUiState {
|
||||
data object Loading : TrashUiState
|
||||
data object Empty : TrashUiState
|
||||
data class Success(val notifications: List<CapturedNotification>) : TrashUiState
|
||||
}
|
||||
|
||||
sealed interface TrashAction {
|
||||
data class Restored(val count: Int) : TrashAction
|
||||
data class PermanentlyDeleted(val count: Int) : TrashAction
|
||||
data object RestoredAll : TrashAction
|
||||
data object EmptiedTrash : TrashAction
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import android.content.Context
|
|||
import android.content.SharedPreferences
|
||||
import android.content.res.Resources
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.roundingmobile.sni.R
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import android.service.notification.NotificationListenerService
|
|||
import android.service.notification.StatusBarNotification
|
||||
import android.util.Log
|
||||
import com.roundingmobile.sni.domain.model.FilterAction
|
||||
import com.roundingmobile.sni.data.local.db.dao.AppActionDao
|
||||
import com.roundingmobile.sni.domain.repository.FilterRuleRepository
|
||||
import com.roundingmobile.sni.domain.repository.NotificationRepository
|
||||
import com.roundingmobile.sni.domain.usecase.FilterRuleEngine
|
||||
|
|
@ -42,9 +43,10 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||
@Inject lateinit var filterRuleEngine: FilterRuleEngine
|
||||
@Inject lateinit var appIconStorage: com.roundingmobile.sni.util.AppIconStorage
|
||||
@Inject lateinit var retentionCleanup: com.roundingmobile.sni.domain.usecase.RetentionCleanupUseCase
|
||||
@Inject lateinit var appActionDao: AppActionDao
|
||||
@Inject lateinit var prefs: SharedPreferences
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// In-memory dedup: notification key -> timestamp of last processing
|
||||
private val recentKeys = ConcurrentHashMap<String, Long>()
|
||||
|
|
@ -66,7 +68,10 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||
|
||||
// Prune old entries periodically
|
||||
if (recentKeys.size > 100) {
|
||||
recentKeys.entries.removeAll { now - it.value > DEDUP_WINDOW_MS }
|
||||
recentKeys.keys.removeAll { key ->
|
||||
val ts = recentKeys[key] ?: return@removeAll true
|
||||
now - ts > DEDUP_WINDOW_MS
|
||||
}
|
||||
}
|
||||
|
||||
serviceScope.launch {
|
||||
|
|
@ -75,6 +80,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||
|
||||
val appName = resolveAppName(sbn.packageName)
|
||||
appIconStorage.saveIfNeeded(sbn.packageName)
|
||||
saveActionLabels(sbn)
|
||||
var parsed = NotificationParser.parse(sbn, appName)
|
||||
|
||||
// Capture media (stickers, images) if enabled
|
||||
|
|
@ -128,7 +134,10 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||
cooldowns[cooldownKey] = now + durationMs
|
||||
// Prune expired cooldowns
|
||||
if (cooldowns.size > 50) {
|
||||
cooldowns.entries.removeAll { now > it.value }
|
||||
cooldowns.keys.removeAll { key ->
|
||||
val exp = cooldowns[key] ?: return@removeAll true
|
||||
now > exp
|
||||
}
|
||||
}
|
||||
// Save the first notification normally
|
||||
}
|
||||
|
|
@ -356,7 +365,12 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||
private fun resolveAppName(packageName: String): String {
|
||||
return try {
|
||||
val pm = applicationContext.packageManager
|
||||
val appInfo = pm.getApplicationInfo(packageName, 0)
|
||||
val appInfo = if (android.os.Build.VERSION.SDK_INT >= 33) {
|
||||
pm.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pm.getApplicationInfo(packageName, 0)
|
||||
}
|
||||
pm.getApplicationLabel(appInfo).toString()
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
packageName
|
||||
|
|
@ -365,11 +379,14 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||
|
||||
override fun onListenerConnected() {
|
||||
instance = this
|
||||
// Recreate scope in case service was rebound after onDestroy
|
||||
if (!serviceScope.coroutineContext[kotlinx.coroutines.Job]!!.isActive) {
|
||||
serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
}
|
||||
// Run retention cleanup on service start
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val mediaDir = java.io.File(filesDir, "media")
|
||||
retentionCleanup.execute(mediaDir)
|
||||
retentionCleanup.execute()
|
||||
} catch (e: Exception) {
|
||||
Log.e("SNI", "Retention cleanup failed", e)
|
||||
}
|
||||
|
|
@ -513,6 +530,21 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun saveActionLabels(sbn: StatusBarNotification) {
|
||||
val actions = sbn.notification.actions ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
for (action in actions) {
|
||||
val label = action.title?.toString() ?: continue
|
||||
if (label.isBlank()) continue
|
||||
val hasRemoteInput = action.remoteInputs?.any { it.allowFreeFormInput } == true
|
||||
try {
|
||||
appActionDao.upsert(sbn.packageName, label, hasRemoteInput, now)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to save action label: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findActiveNotification(packageName: String, notificationId: Int, tag: String?): StatusBarNotification? {
|
||||
val active = activeNotifications ?: return null
|
||||
return active.find { sbn ->
|
||||
|
|
|
|||
|
|
@ -89,4 +89,6 @@ data class ConsecutiveGroup(
|
|||
val latest: CapturedNotification get() = notifications.first()
|
||||
val packageName: String get() = latest.packageName
|
||||
val appName: String get() = latest.appName
|
||||
val hasBookmark: Boolean get() = notifications.any { it.isBookmarked }
|
||||
val hasUnread: Boolean get() = notifications.any { !it.isRead }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
<string name="sort_by_app">Nach App</string>
|
||||
|
||||
<!-- Auto-action & deletion reason -->
|
||||
<string name="filter_action_execute">Aktionsschaltfläche antippen</string>
|
||||
<string name="filter_action_execute">Aktionsschaltfläche drücken</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>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
<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_action_execute">pulsar 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>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@
|
|||
<string name="action_clear_all">Clear all</string>
|
||||
<string name="action_delete_all">Delete all</string>
|
||||
<string name="action_undo">Undo</string>
|
||||
<string name="detail_deleted_snackbar">Notification deleted</string>
|
||||
<string name="cd_previous_notification">Previous notification</string>
|
||||
<string name="cd_next_notification">Next notification</string>
|
||||
<string name="action_upgrade_to_pro">Upgrade to Pro</string>
|
||||
<string name="action_not_now">Not now</string>
|
||||
<string name="action_use">Use</string>
|
||||
|
|
@ -194,13 +197,13 @@
|
|||
<string name="filter_operator_all">all</string>
|
||||
|
||||
<!-- Filter rule: actions -->
|
||||
<string name="filter_action_dont_save">don\'t save it</string>
|
||||
<string name="filter_action_save_dismiss">save & dismiss from shade</string>
|
||||
<string name="filter_action_block">don\'t save & dismiss</string>
|
||||
<string name="filter_action_save_as_read">save as read</string>
|
||||
<string name="filter_action_cooldown">cooldown</string>
|
||||
<string name="filter_action_dont_save">skip it (don\'t save)</string>
|
||||
<string name="filter_action_save_dismiss">save & clear notification</string>
|
||||
<string name="filter_action_block">block it (skip & clear)</string>
|
||||
<string name="filter_action_save_as_read">save silently (mark read)</string>
|
||||
<string name="filter_action_cooldown">limit frequency</string>
|
||||
<string name="filter_action_alert">alert me</string>
|
||||
<string name="filter_action_execute">tap action button</string>
|
||||
<string name="filter_action_execute">hit action button</string>
|
||||
|
||||
<!-- Filter rule: cooldown durations -->
|
||||
<string name="filter_cooldown_1m">1 minute</string>
|
||||
|
|
@ -209,13 +212,18 @@
|
|||
<string name="filter_cooldown_15m">15 minutes</string>
|
||||
<string name="filter_cooldown_30m">30 minutes</string>
|
||||
<string name="filter_cooldown_1h">1 hour</string>
|
||||
<string name="filter_cooldown_2h">2 hours</string>
|
||||
<string name="filter_cooldown_5h">5 hours</string>
|
||||
<string name="filter_cooldown_8h">8 hours</string>
|
||||
<string name="filter_cooldown_12h">12 hours</string>
|
||||
<string name="filter_cooldown_24h">24 hours</string>
|
||||
|
||||
<!-- Filter rule: match fields -->
|
||||
<string name="filter_field_title">title</string>
|
||||
<string name="filter_field_text">message text</string>
|
||||
<string name="filter_field_title_or_text">title or text</string>
|
||||
<string name="filter_field_text">message</string>
|
||||
<string name="filter_field_title_or_text">title or message</string>
|
||||
<string name="filter_field_app_name">app name</string>
|
||||
<string name="filter_field_package_name">package name</string>
|
||||
<string name="filter_field_package_name">app ID (advanced)</string>
|
||||
|
||||
<!-- Filter rule: match types -->
|
||||
<string name="filter_match_contains">contains</string>
|
||||
|
|
@ -273,8 +281,13 @@
|
|||
<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_enter_title">enter title to match…</string>
|
||||
<string name="filter_sentence_enter_app_name">enter app name…</string>
|
||||
<string name="filter_sentence_enter_package">enter package name…</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_known_actions">Known buttons for this app:</string>
|
||||
<string name="filter_execute_actions_note">Not all notifications from this app may have these buttons</string>
|
||||
<string name="filter_execute_also_delete">Also remove from our list if action succeeds</string>
|
||||
|
||||
<!-- Filter rule app picker -->
|
||||
|
|
@ -396,4 +409,67 @@
|
|||
<string name="media_clear_all">Clear all</string>
|
||||
<string name="settings_media_storage">Media & Storage</string>
|
||||
<string name="settings_media_storage_subtitle">Notification images, stickers, storage usage</string>
|
||||
|
||||
<!-- Backup & Restore -->
|
||||
<string name="backup_title">Backup & Restore</string>
|
||||
<string name="backup_current_data">Current data</string>
|
||||
<string name="backup_notifications">Notifications</string>
|
||||
<string name="backup_deleted_items">Deleted items</string>
|
||||
<string name="backup_filter_rules">Filter rules</string>
|
||||
<string name="backup_hidden_apps">Hidden apps</string>
|
||||
<string name="backup_media_files">Media files</string>
|
||||
|
||||
<string name="backup_create_title">Create backup</string>
|
||||
<string name="backup_create_button">Back up to Downloads</string>
|
||||
<string name="backup_include_deleted">Include deleted items</string>
|
||||
<string name="backup_include_deleted_count">%d soft-deleted notifications</string>
|
||||
<string name="backup_include_media">Include media files</string>
|
||||
<string name="backup_include_media_count">%1$d files (%2$s)</string>
|
||||
<string name="backup_no_media">No media files saved</string>
|
||||
<string name="backup_include_filter_rules">Include filter rules</string>
|
||||
<string name="backup_include_rules_count">%d rules</string>
|
||||
<string name="backup_include_hidden_apps">Include hidden apps list</string>
|
||||
<string name="backup_include_hidden_count">%d hidden apps</string>
|
||||
<string name="backup_saved_to">Backup saved: %s</string>
|
||||
|
||||
<string name="backup_restore_title">Restore from backup</string>
|
||||
<string name="backup_restore_description">Select an SNI backup zip file from your device to restore data.</string>
|
||||
<string name="backup_restore_button">Choose backup file</string>
|
||||
<string name="backup_restore_confirm_title">Restore backup?</string>
|
||||
<string name="backup_restore_info">This backup contains %1$d notifications and %2$d filter rules.</string>
|
||||
<string name="backup_restore_has_deleted">Includes soft-deleted notifications</string>
|
||||
<string name="backup_restore_has_media">Includes %d media files</string>
|
||||
<string name="backup_restore_what_to_restore">What to restore:</string>
|
||||
<string name="backup_restore_warning">Existing data with the same IDs will be overwritten.</string>
|
||||
<string name="backup_restore_confirm">Restore</string>
|
||||
<string name="backup_restore_complete">Restored %1$d notifications, %2$d rules</string>
|
||||
|
||||
<string name="settings_backup">Backup & Restore</string>
|
||||
<string name="settings_backup_subtitle">Back up or restore your notification data</string>
|
||||
|
||||
<!-- Trash screen -->
|
||||
<string name="trash_title">Trash</string>
|
||||
<string name="trash_count">%d deleted items</string>
|
||||
<string name="trash_empty_state">Trash is empty</string>
|
||||
<string name="trash_empty_state_subtitle">Deleted notifications will appear here</string>
|
||||
<string name="trash_restore">Restore</string>
|
||||
<string name="trash_delete_forever">Delete permanently</string>
|
||||
<string name="trash_restore_all">Restore all</string>
|
||||
<string name="trash_empty">Empty trash</string>
|
||||
<string name="trash_empty_confirm_title">Empty trash?</string>
|
||||
<string name="trash_empty_confirm_message">All deleted notifications will be permanently removed. This cannot be undone.</string>
|
||||
<string name="trash_empty_confirm_button">Empty trash</string>
|
||||
<string name="trash_restore_all_title">Restore all?</string>
|
||||
<string name="trash_restore_all_message">All deleted notifications will be moved back to your timeline.</string>
|
||||
<string name="trash_restore_all_confirm">Restore all</string>
|
||||
<string name="trash_restored">%d item restored</string>
|
||||
<string name="trash_permanently_deleted">Permanently deleted</string>
|
||||
<string name="trash_all_restored">All items restored</string>
|
||||
<string name="trash_emptied">Trash emptied</string>
|
||||
<string name="trash_reason_auto_action">Auto-action: %s</string>
|
||||
<string name="trash_reason_unknown">Deleted</string>
|
||||
<string name="settings_trash">Trash</string>
|
||||
<string name="settings_trash_subtitle">View and restore deleted notifications</string>
|
||||
<string name="settings_trash_count">%d deleted items</string>
|
||||
<string name="settings_trash_empty">Empty</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co
|
|||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
|
|
|
|||
|
|
@ -22,5 +22,5 @@ dependencyResolutionManagement {
|
|||
}
|
||||
}
|
||||
|
||||
rootProject.name = "AlertVault"
|
||||
rootProject.name = "SmartNotificationInbox"
|
||||
include(":app")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue