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:
jima 2026-03-20 19:36:44 +01:00
parent a0fc459e7a
commit 651928f246
57 changed files with 2832 additions and 201 deletions

View file

@ -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"
}

View file

@ -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

View file

@ -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')"
]
}
}

View file

@ -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" />

View file

@ -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"
}
}

View file

@ -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
)

View file

@ -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()
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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()
}

View file

@ -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>

View file

@ -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>)
}

View file

@ -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>)
}

View file

@ -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,

View file

@ -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
)

View file

@ -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()
}
}
}
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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 ---

View file

@ -0,0 +1,5 @@
package com.roundingmobile.sni.domain.provider
interface MediaCleanupProvider {
fun cleanMediaOlderThan(cutoffMs: Long)
}

View file

@ -0,0 +1,6 @@
package com.roundingmobile.sni.domain.provider
interface RetentionPreferences {
fun getRetentionDays(): Int
fun getMediaRetentionDays(): Int
}

View file

@ -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)

View file

@ -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 {

View file

@ -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)

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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
)
}
}
}
}

View file

@ -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
}

View file

@ -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,

View file

@ -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))
}

View file

@ -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(
}
)
}

View file

@ -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
}

View file

@ -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
}
}
}
}

View file

@ -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
)
}

View file

@ -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
}

View file

@ -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 }
}

View file

@ -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

View file

@ -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,

View file

@ -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>()

View file

@ -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),

View file

@ -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
}
}

View file

@ -1 +0,0 @@
package com.roundingmobile.sni.presentation.theme

View file

@ -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
)
}
}
}
}

View file

@ -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"

View file

@ -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() }

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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 ->

View file

@ -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 }
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 &amp; dismiss from shade</string>
<string name="filter_action_block">don\'t save &amp; 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 &amp; clear notification</string>
<string name="filter_action_block">block it (skip &amp; 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 &amp; Storage</string>
<string name="settings_media_storage_subtitle">Notification images, stickers, storage usage</string>
<!-- Backup & Restore -->
<string name="backup_title">Backup &amp; 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 &amp; 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>

View file

@ -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" }

View file

@ -22,5 +22,5 @@ dependencyResolutionManagement {
}
}
rootProject.name = "AlertVault"
rootProject.name = "SmartNotificationInbox"
include(":app")