first commit

This commit is contained in:
jima 2026-03-16 10:05:36 +01:00 committed by jima
commit b7518b1edc
205 changed files with 10664 additions and 0 deletions

73
.gitignore vendored Normal file
View file

@ -0,0 +1,73 @@
# =========================
# IDE (Android Studio / IntelliJ)
# =========================
.idea/
*.iml
# =========================
# Gradle / Kotlin
# =========================
.gradle/
build/
out/
/.kotlin/
# =========================
# Local configuration
# =========================
local.properties
# =========================
# Native / C++ builds
# =========================
.cxx/
.externalNativeBuild/
# =========================
# Generated / temporary
# =========================
captures/
.navigation/
bin/
gen/
proguard/
*.log
# =========================
# Build artifacts
# =========================
*.apk
*.ap_
*.aab
*.dex
*.class
# =========================
# Keystores (VERY important)
# =========================
*.jks
*.keystore
# =========================
# OS junk
# =========================
.DS_Store
Thumbs.db
# =========================
# Tools / plugins / others
# =========================
/.continue/
/eva/
/tellnext/
/browsernext/
/speakernext/
/transfernext/
/wtCaptures/
/logs/
/tests/
*.sh
*.swp
TellNextTrace*.txt
BNWT.txt

598
CLAUDE.md Normal file
View file

@ -0,0 +1,598 @@
# NotificationSaver — Claude Code Instructions
## READ FIRST
Read `NotificationSaver-ARCHITECTURE.md` before doing anything. It contains the full architecture, module structure, database schema, all features, and the test instrumentation protocol.
---
## Project Philosophy
This project follows an **iterative build-test-verify** methodology. You never write a large chunk of code and hope it works. Instead:
1. Write a small, focused piece of code
2. Build it
3. Test it (install, run, verify via ADB)
4. Review it (is it clean? follows standards? no duplication?)
5. Only then move to the next piece
**Quality over speed.** Every file you create must follow Google's Android coding standards, use proper Hilt injection, have no duplicate logic, and be production-ready.
---
## Development Standards
### Mandatory for ALL code
- **Kotlin coding conventions** — official Kotlin style guide
- **Jetpack Compose best practices** — stable types, skip-friendly composables, no side effects in composition
- **Clean Architecture** — domain layer has ZERO Android imports, presentation never touches data layer directly
- **Hilt/Dagger DI** — no manual instantiation of repositories, use cases, or ViewModels. Everything injected.
- **No duplicate code** — if two screens share logic, extract to a use case or shared composable
- **Repository pattern** — interfaces in domain, implementations in data
- **Coroutines + Flow** — suspend for one-shot, Flow for streams. viewModelScope in ViewModels. IO dispatcher for DB.
- **Single Activity** — Compose Navigation for all screens
- **Material 3** — dynamic color, proper theming, dark mode
- **Edge-to-edge** — handle insets properly with `WindowInsets`
- **Proper error handling** — try/catch in data layer, sealed Result types, never crash on data errors
- **No hardcoded strings** — all user-visible text in `strings.xml`
- **Content descriptions** — on all icons and interactive elements for accessibility
- **ProGuard-safe** — keep annotations where needed, test release builds
### Before committing any file, ask yourself:
- Does this class have a single responsibility?
- Is there another class that already does something similar?
- Are all dependencies injected via Hilt?
- Would a senior Android developer approve this code?
- Is the domain layer still free of Android imports?
- Are Compose previews provided for screens?
---
## Build Flavors
Three flavors exist: `free`, `pro`, `testing`.
```bash
# Build commands
./gradlew assembleFreeDebug # Free flavor, debug
./gradlew assembleProDebug # Pro flavor, debug
./gradlew assembleTestingDebug # Testing flavor, debug (this is your main one)
./gradlew assembleProRelease # Pro release (minified)
./gradlew assembleFreeRelease # Free release (minified + ads)
# Install testing flavor
adb install -r app/build/outputs/apk/testing/debug/app-testing-debug.apk
# Launch testing flavor
adb shell am start -n com.rounding.notisaver.testing.debug/com.rounding.notisaver.presentation.MainActivity
# Grant notification listener
adb shell cmd notification allow_listener \
com.rounding.notisaver.testing.debug/com.rounding.notisaver.service.NotificationCaptureService
```
The **testing** flavor is your primary development target. It has all pro features plus the test instrumentation layer. Build and test against it. Periodically also build the free and pro flavors to verify they compile and the flavor-specific code (ads, feature gates) works.
---
## ADB Testing Protocol
### Reading app state
```bash
# Dump all visible UI element positions → logcat
adb shell am broadcast -a com.rounding.notisaver.test.DUMP_UI
adb logcat -d -s CLAUDE_TEST:V
# Dump app state → logcat
adb shell am broadcast -a com.rounding.notisaver.test.DUMP_STATE
adb logcat -d -s CLAUDE_TEST:V
# Stream test logs live
adb logcat -c && adb logcat -s CLAUDE_TEST:V
# Check for crashes
adb logcat -d | grep -E "(CLAUDE_TEST|AndroidRuntime|FATAL)"
```
### Interacting
```bash
# Tap (always DUMP_UI first to get coordinates)
adb shell input tap <x> <y>
# Type text (%s = space)
adb shell input text "hello%sworld"
# Keys
adb shell input keyevent KEYCODE_BACK
adb shell input keyevent KEYCODE_HOME
adb shell input keyevent KEYCODE_ENTER
adb shell input keyevent KEYCODE_DEL # backspace
# Swipe (scroll)
adb shell input swipe 540 1800 540 600 300 # scroll down
# Screenshot
adb shell screencap -p /sdcard/screen.png && adb pull /sdcard/screen.png ./screenshot.png
# UI automator fallback
adb shell uiautomator dump && adb pull /sdcard/window_dump.xml
```
### Injecting test data
```bash
# Single notification
adb shell am broadcast -a com.rounding.notisaver.test.INJECT_NOTIFICATION \
--es package "com.whatsapp" --es app_name "WhatsApp" \
--es title "John" --es text "See you later" \
--es category "message" --ei priority 2
# Batch
adb shell am broadcast -a com.rounding.notisaver.test.INJECT_BATCH \
--es json '[{"package":"com.whatsapp","title":"John","text":"Hi"},{"package":"com.google.android.gm","app_name":"Gmail","title":"Anna","text":"Meeting"}]'
# Remove notification
adb shell am broadcast -a com.rounding.notisaver.test.REMOVE_NOTIFICATION --ei notification_id 123
# Navigate directly
adb shell am broadcast -a com.rounding.notisaver.test.NAVIGATE --es screen "settings"
# Set preference
adb shell am broadcast -a com.rounding.notisaver.test.SET_STATE --es key "retention_days" --es value "7"
# Clear DB
adb shell am broadcast -a com.rounding.notisaver.test.CLEAR_DB
# Trigger workers
adb shell am broadcast -a com.rounding.notisaver.test.TRIGGER_CLEANUP
adb shell am broadcast -a com.rounding.notisaver.test.TRIGGER_DIGEST
```
### CRITICAL RULE: Always DUMP_UI before tapping
Never guess coordinates. Always:
1. `adb shell am broadcast -a com.rounding.notisaver.test.DUMP_UI`
2. `adb logcat -d -s CLAUDE_TEST:V` → parse VIEW lines
3. Calculate center of target element: `x = (left+right)/2, y = (top+bottom)/2`
4. `adb shell input tap x y`
---
## Step-by-Step Implementation Plan
**Follow these phases IN ORDER.** Each phase builds on the previous one. Do NOT skip ahead. Each step within a phase has a verification checkpoint — complete it before moving on.
---
### PHASE 0: Project Skeleton
#### Step 0.1 — Gradle setup
Create the project structure with:
- Project-level `build.gradle.kts` with Kotlin, Hilt, Firebase plugins
- `gradle/libs.versions.toml` version catalog
- App-level `build.gradle.kts` with all 3 flavors configured
- `settings.gradle.kts`
**Verify:** `./gradlew assembleTestingDebug` compiles (empty app is fine).
#### Step 0.2 — Application class + Hilt
- `App.kt` with `@HiltAndroidApp`
- `MainActivity.kt` with `@AndroidEntryPoint` and empty Compose content
- `AndroidManifest.xml` with application, activity, theme
**Verify:** Build, install on emulator, app launches showing blank screen. No crash in logcat.
#### Step 0.3 — Theme
- Material 3 theme with dynamic color
- Dark/light mode support
- Color.kt, Type.kt, Theme.kt
**Verify:** Build, install, verify dark mode toggle works via `adb shell cmd uimode night yes` / `adb shell cmd uimode night no`. Screenshot both modes.
#### Step 0.4 — Navigation skeleton
- `Route.kt` sealed interface with all routes
- `NavGraph.kt` with empty placeholder screens
- Bottom navigation bar: Timeline, Stats, Settings
- Wire up in MainActivity
**Verify:** Build, install, tap each bottom nav item. DUMP_UI to confirm navigation works. Screenshot each tab.
#### Step 0.5 — Test instrumentation layer
- `TestInstrumentation.kt` interface in main
- `NoOpTestInstrumentation` (used by free/pro flavors)
- `LiveTestInstrumentation` with `TestLogEmitter`, `UIPositionTracker` (testing flavor)
- `TestBroadcastReceiver` with DUMP_UI, DUMP_STATE
- `testTrack()` Modifier extension
- Hilt modules for each flavor
**Verify:**
1. Build and install testing flavor
2. `adb shell am broadcast -a com.rounding.notisaver.test.DUMP_UI`
3. `adb logcat -d -s CLAUDE_TEST:V` → should see VIEW lines for bottom nav items
4. Build free flavor → verify it compiles (no-op instrumentation)
5. Build pro flavor → verify it compiles
#### Step 0.6 — Code review checkpoint
Review ALL code written so far:
- Is Hilt injection correct everywhere?
- No Android imports in domain layer?
- No duplicate code?
- Naming conventions followed?
- ProGuard rules in place?
Fix any issues before proceeding.
---
### PHASE 1: Database + Capture Service
#### Step 1.1 — Room database
- `AppDatabase.kt` with all entities and DAOs
- `NotificationEntity.kt`, `NotificationDao.kt` (CRUD + Flow queries + FTS)
- `HiddenAppEntity.kt`, `HiddenAppDao.kt`
- `Converters.kt` for JSON fields
- Database module for Hilt
**Verify:**
1. Build
2. Write a quick unit test or use the testing flavor to insert/query a row via broadcast
3. Check logcat for any Room errors
#### Step 1.2 — Domain models + repository interface
- `CapturedNotification.kt` and other domain models
- `NotificationRepository.kt` interface in domain
- `NotificationRepositoryImpl.kt` in data with mapper
- `NotificationMapper.kt` (entity ↔ domain)
**Verify:** Build compiles. Domain models have no Android imports. Repository interface is clean.
#### Step 1.3 — NotificationListenerService
- `NotificationCaptureService.kt` — capture posted/removed notifications
- `NotificationParser.kt` — extract all fields from StatusBarNotification
- Wire through repository to Room
- Handle hidden apps (skip capture)
**Verify:**
1. Build and install testing flavor
2. Grant notification listener permission via adb
3. Send a test notification from another app (or use `adb shell am` to trigger a system notification)
4. DUMP_STATE → check `db_count` increased
5. Check logcat for EVENT notification_captured
#### Step 1.4 — FakeNotificationInjector (testing flavor)
- Implement INJECT_NOTIFICATION and INJECT_BATCH broadcast handlers
- Insert directly into Room via repository (bypass service)
- Wire up CLEAR_DB, DUMP_STATE with real db counts
**Verify:**
1. CLEAR_DB
2. Inject 5 notifications via broadcast
3. DUMP_STATE → db_count=5
4. CLEAR_DB → DUMP_STATE → db_count=0
#### Step 1.5 — Code review checkpoint
Review database layer, service, repository, mapper. Check:
- Proper indices on Room entities?
- Mapper handles nulls correctly?
- Service properly handles edge cases (null title, missing extras)?
- No memory leaks in service?
---
### PHASE 2: Timeline Screen
#### Step 2.1 — GetTimelineUseCase
- Use case that returns `Flow<List<CapturedNotification>>` from repository
- Supports sort order parameter
- Filters hidden apps
**Verify:** Build compiles. Use case has no Android imports.
#### Step 2.2 — TimelineViewModel
- Hilt ViewModel
- Exposes StateFlow of timeline data
- Sort order state
- Loading/error/empty states
**Verify:** Build compiles. ViewModel uses viewModelScope correctly.
#### Step 2.3 — TimelineScreen composable
- LazyColumn with NotificationItem composables
- Time block headers (Today, Yesterday, etc.)
- Sort toggle in top bar
- Pull-to-refresh (if needed) or auto-update via Flow
- Empty state when no notifications
- `testTrack()` on every interactive element
**Verify:**
1. Build and install
2. CLEAR_DB → inject 10 notifications
3. DUMP_UI → verify notif_item_0 through notif_item_9 appear with correct bounds
4. Screenshot → verify layout looks good
5. Tap sort toggle → DUMP_UI → verify order changed
6. CLEAR_DB → verify empty state appears
7. Screenshot empty state
#### Step 2.4 — NotificationItem shared composable
- App icon, app name, title, text preview, timestamp
- Compact and detailed layout variants
- Deleted message badge
- Bookmark indicator
- Shared across timeline, search results, filter results
**Verify:** Visual inspection via screenshots in both compact and detailed modes.
#### Step 2.5 — Code review checkpoint
- Is LazyColumn using `key` parameter for stable recomposition?
- Are composables skip-friendly (stable parameters)?
- Is the shared NotificationItem truly reusable?
- No business logic in composables?
---
### PHASE 3: Detail Screen
#### Step 3.1 — GetNotificationDetailUseCase
**Verify:** Build compiles. Pure domain.
#### Step 3.2 — DetailScreen + ViewModel
- Full notification content display
- Actions: copy, share, open source app, bookmark toggle
- Extras section (collapsible)
- Navigation from timeline item tap
**Verify:**
1. Inject notification, tap it on timeline (using DUMP_UI coordinates)
2. DUMP_UI on detail screen → verify all elements present
3. Tap copy → verify clipboard (or just verify no crash)
4. Tap back → verify return to timeline
5. Screenshot detail screen
---
### PHASE 4: Search
#### Step 4.1 — Room FTS setup
- Add FTS virtual table for notifications
- `SearchNotificationsUseCase`
**Verify:** Build compiles. Test search query returns correct results via DUMP_STATE.
#### Step 4.2 — SearchScreen + ViewModel
- Search bar with debounced input
- Results list using shared NotificationItem
- Empty results state
- Recent searches (optional)
**Verify:**
1. Inject 20 notifications, 3 with "Amazon" in text
2. Navigate to search → type "Amazon"
3. DUMP_UI → verify 3 results
4. Screenshot
5. Clear search → verify all results gone
6. Type gibberish → verify empty state
---
### PHASE 5: Filter
#### Step 5.1 — FilterByAppUseCase + FilterScreen
- Checkbox list of all apps with notification counts
- Select all / deselect all
- Persist filter in app_filter_prefs
**Verify:**
1. Inject from 4 different apps
2. Open filter → DUMP_UI → verify 4 apps listed
3. Deselect one → go to timeline → verify filtered
4. Go back to filter → reselect → verify restored
---
### PHASE 6: Settings + Security
#### Step 6.1 — SettingsScreen
- Retention period selector
- Hidden apps management
- App lock toggle
- Theme mode toggle
- Developer mode toggle (shows raw data viewer)
- About section
**Verify:** Build, install, navigate to settings, toggle options, DUMP_STATE to confirm persisted.
#### Step 6.2 — AppLockManager + LockScreen
- PIN setup/verification with EncryptedSharedPreferences
- BiometricPrompt integration
- Lock on app background
**Verify:**
1. Enable PIN in settings
2. Press home → relaunch
3. DUMP_UI → verify lock screen shown
4. Enter PIN via adb input
5. DUMP_UI → verify timeline shown
#### Step 6.3 — Onboarding flow
- Welcome → permission grant → optional lock setup → done
**Verify:** Clear app data, relaunch, walk through onboarding via ADB taps. Verify permission prompt appears.
---
### PHASE 7: Deleted Message Detection + Conversation View
#### Step 7.1 — DetectDeletedMessageUseCase
- Logic for flagging rapid removals
**Verify:** Inject notification → wait 1s → remove → check flag in DB.
#### Step 7.2 — ConversationScreen
- Group by sender, chat-bubble layout
**Verify:** Inject 10 messages between 2 senders. Open conversation view. Screenshot.
---
### PHASE 8: Statistics
#### Step 8.1 — ComputeStatisticsUseCase + StatsScreen
- Per-app counts, frequency graph, noisy apps ranking
**Verify:** Inject 50 varied notifications. Navigate to stats. Screenshot. Verify numbers match.
---
### PHASE 9: Export
#### Step 9.1 — ExportNotificationsUseCase + ExportScreen
- CSV, JSON, TXT, PDF with date/app filters
- Save to Downloads
**Verify:** Export CSV → pull via adb → check content matches DB data.
---
### PHASE 10: Firebase + Crashlytics
#### Step 10.1 — Firebase setup
- Add google-services.json per flavor
- Initialize Crashlytics
- Add custom keys (flavor, version)
- Add non-fatal exception logging in catch blocks
**Verify:** Build all 3 flavors. Force a crash in testing flavor → check Crashlytics dashboard (or just verify no build errors).
#### Step 10.2 — Analytics events
- Screen views, feature usage events (aggregate only, no PII)
**Verify:** Build free + pro. Verify events fire (logcat Firebase debug).
---
### PHASE 11: Ads (free flavor)
#### Step 11.1 — AdMob integration
- `AdsModule.kt` in free flavor
- `AdManager.kt` — initialize, load banner
- `AdBanner.kt` composable — bottom of TimelineScreen only
- Wrap in `if (BuildConfig.ADS_ENABLED)`
**Verify:**
1. Build free flavor
2. Install → verify banner appears on timeline
3. Navigate to other screens → verify NO ads anywhere else
4. Build pro flavor → verify no ad code compiled
5. Build testing flavor → verify no ad code compiled
#### Step 11.2 — PremiumGate composable
- Shown when free users tap pro features
- "Upgrade to Pro" with feature list
- Link to Play Store listing
**Verify:** Build free flavor, tap export (pro feature) → verify gate shown instead.
---
### PHASE 12: Polish + Release Prep
#### Step 12.1 — Widget
- Home screen widget showing last N notifications
#### Step 12.2 — Regex filters + keyword alerts (pro)
#### Step 12.3 — Notification diffing UI (pro)
#### Step 12.4 — Backup/restore (pro)
#### Step 12.5 — Full regression test
Run all test scenarios from the architecture doc (Scenarios 1-10) against the testing flavor. Fix anything broken.
#### Step 12.6 — Build release APKs
```bash
./gradlew assembleFreeRelease
./gradlew assembleProRelease
```
Verify both build with ProGuard enabled. Install release builds on emulator and smoke test.
#### Step 12.7 — Final code review
Walk through every file. Check:
- No TODO comments left
- No hardcoded strings
- No unused imports
- No duplicate logic
- All Hilt modules correct
- All content descriptions present
- ProGuard rules complete
- Crashlytics not logging PII
- Ads only in free flavor
- Test instrumentation only in testing flavor
---
## Troubleshooting
### App won't install
```bash
adb shell pm list packages | grep notisaver
adb uninstall com.rounding.notisaver.testing.debug
adb install -r app/build/outputs/apk/testing/debug/app-testing-debug.apk
```
### Notification listener not working
```bash
adb shell cmd notification list_listeners
adb shell cmd notification allow_listener \
com.rounding.notisaver.testing.debug/com.rounding.notisaver.service.NotificationCaptureService
```
### No CLAUDE_TEST output
- Verify testing flavor installed: `adb shell pm list packages | grep testing`
- App running: `adb shell pidof com.rounding.notisaver.testing.debug`
- Try DUMP_STATE and wait before reading logcat
### Build failures
```bash
./gradlew clean assembleTestingDebug --stacktrace
java -version # must be 17+
```
### Firebase issues
- Verify google-services.json exists for the flavor being built
- Check `app/src/<flavor>/google-services.json`
### Emulator
```bash
adb devices # list connected
emulator -list-avds # list available
emulator -avd <name> & # start
```
---
## Editor
Use **vim** for all terminal text editing. Never use nano.
---
## Decision Authority
You have full authority to make UI/UX decisions. When choosing between approaches:
- Pick the one that's most user-friendly
- Pick the one that follows Material 3 guidelines
- Pick the one that requires less code (simpler is better)
- Pick the one a senior Android developer would choose
If a decision is genuinely ambiguous (e.g., bottom sheet vs full screen for filters), just pick one, implement it, test it, and note your reasoning in a code comment. We can always change it later.
You also decide:
- Exact color schemes (within Material 3 dynamic color system)
- Icon choices (Material Icons)
- Animation details (keep subtle and functional)
- Spacing and padding values (follow Material 3 specs)
- Error message wording
- Empty state illustrations or text
- Settings organization and grouping

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

165
app/build.gradle.kts Normal file
View file

@ -0,0 +1,165 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
import java.io.FileInputStream
import java.util.Properties
val keystorePropsFile = file("${System.getProperty("user.home")}/AndroidStudioProjects/rounding/.keystore/rmt.properties")
val keystoreProps = Properties()
if (keystorePropsFile.exists()) {
keystoreProps.load(FileInputStream(keystorePropsFile))
}
android {
namespace = "com.roundingmobile.notisaver"
compileSdk = 36
signingConfigs {
create("rmt") {
storeFile = file("${System.getProperty("user.home")}/AndroidStudioProjects/rounding/.keystore/${keystoreProps.getProperty("keystore", "")}")
storePassword = keystoreProps.getProperty("keystore.password", "")
keyAlias = keystoreProps.getProperty("release.alias", "")
keyPassword = keystoreProps.getProperty("alias.password", "")
}
}
defaultConfig {
applicationId = "com.roundingmobile.notisaver"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0.beta01"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
flavorDimensions += "tier"
productFlavors {
create("free") {
dimension = "tier"
applicationId = "com.roundingmobile.notisaver"
buildConfigField("boolean", "IS_FREE", "true")
buildConfigField("boolean", "IS_PRO", "false")
buildConfigField("boolean", "TEST_INSTRUMENTED", "false")
buildConfigField("boolean", "ADS_ENABLED", "true")
buildConfigField("int", "RETENTION_LIMIT_HOURS", "24")
}
create("pro") {
dimension = "tier"
applicationId = "com.roundingmobile.notisaver.pro"
buildConfigField("boolean", "IS_FREE", "false")
buildConfigField("boolean", "IS_PRO", "true")
buildConfigField("boolean", "TEST_INSTRUMENTED", "false")
buildConfigField("boolean", "ADS_ENABLED", "false")
buildConfigField("int", "RETENTION_LIMIT_HOURS", "0")
}
create("dev") {
dimension = "tier"
applicationId = "com.roundingmobile.notisaver.dev"
buildConfigField("boolean", "IS_FREE", "false")
buildConfigField("boolean", "IS_PRO", "true")
buildConfigField("boolean", "TEST_INSTRUMENTED", "true")
buildConfigField("boolean", "ADS_ENABLED", "false")
buildConfigField("int", "RETENTION_LIMIT_HOURS", "0")
}
}
buildTypes {
release {
isMinifyEnabled = false
isShrinkResources = false
signingConfig = signingConfigs.getByName("rmt")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("rmt")
// No applicationIdSuffix — debug and release share the same package name
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
// AndroidX Core
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
debugImplementation(libs.compose.ui.tooling)
// Navigation
implementation(libs.navigation.compose)
// Lifecycle
implementation(libs.lifecycle.runtime.compose)
implementation(libs.lifecycle.viewmodel.compose)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// WorkManager
implementation(libs.work.runtime.ktx)
implementation(libs.hilt.work)
// Coroutines
implementation(libs.coroutines.core)
implementation(libs.coroutines.android)
// Security
implementation(libs.security.crypto)
implementation(libs.biometric)
// Serialization
implementation(libs.kotlinx.serialization.json)
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}

30
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,30 @@
# Firebase Crashlytics
-keepattributes SourceFile,LineNumberTable
-keep public class * extends java.lang.Exception
# Room
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
# Hilt
-keep class dagger.hilt.** { *; }
-keep class * extends dagger.hilt.android.lifecycle.HiltViewModel
# Kotlin Serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep @Serializable classes
-keep,includedescriptorclasses class com.roundingmobile.notisaver.**$$serializer { *; }
-keepclassmembers class com.roundingmobile.notisaver.** {
*** Companion;
}
-keepclasseswithmembers class com.roundingmobile.notisaver.** {
kotlinx.serialization.KSerializer serializer(...);
}

View file

@ -0,0 +1,320 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "49ae503adf3a70e553335f5f614e56ce",
"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, `extras_json` TEXT, `actions_json` TEXT, `icon_uri` TEXT, `created_at` INTEGER NOT NULL)",
"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": "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
}
],
"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`)"
}
]
},
{
"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": "keyword_alerts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyword` TEXT NOT NULL, `is_regex` INTEGER NOT NULL, `is_active` INTEGER NOT NULL, `created_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyword",
"columnName": "keyword",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isRegex",
"columnName": "is_regex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "is_active",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"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"
]
}
}
],
"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, '49ae503adf3a70e553335f5f614e56ce')"
]
}
}

View file

@ -0,0 +1,320 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "49ae503adf3a70e553335f5f614e56ce",
"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, `extras_json` TEXT, `actions_json` TEXT, `icon_uri` TEXT, `created_at` INTEGER NOT NULL)",
"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": "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
}
],
"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`)"
}
]
},
{
"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": "keyword_alerts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyword` TEXT NOT NULL, `is_regex` INTEGER NOT NULL, `is_active` INTEGER NOT NULL, `created_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyword",
"columnName": "keyword",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isRegex",
"columnName": "is_regex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "is_active",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"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"
]
}
}
],
"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, '49ae503adf3a70e553335f5f614e56ce')"
]
}
}

View file

@ -0,0 +1,407 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "fdae8dd35af19ff1c55b3b47d655d07d",
"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, `extras_json` TEXT, `actions_json` TEXT, `icon_uri` TEXT, `created_at` INTEGER NOT NULL)",
"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": "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
}
],
"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`)"
}
]
},
{
"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": "keyword_alerts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyword` TEXT NOT NULL, `is_regex` INTEGER NOT NULL, `is_active` INTEGER NOT NULL, `created_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyword",
"columnName": "keyword",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isRegex",
"columnName": "is_regex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "is_active",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"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, `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": "packageName",
"columnName": "package_name",
"affinity": "TEXT"
},
{
"fieldPath": "appName",
"columnName": "app_name",
"affinity": "TEXT"
},
{
"fieldPath": "isEnabled",
"columnName": "is_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isBuiltIn",
"columnName": "is_built_in",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_filter_rules_is_enabled",
"unique": false,
"columnNames": [
"is_enabled"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_filter_rules_is_enabled` ON `${TABLE_NAME}` (`is_enabled`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fdae8dd35af19ff1c55b3b47d655d07d')"
]
}
}

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver
android:name="com.roundingmobile.notisaver.instrumentation.TestBroadcastReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.roundingmobile.notisaver.test.DUMP_UI" />
<action android:name="com.roundingmobile.notisaver.test.DUMP_STATE" />
<action android:name="com.roundingmobile.notisaver.test.NAVIGATE" />
<action android:name="com.roundingmobile.notisaver.test.SET_STATE" />
<action android:name="com.roundingmobile.notisaver.test.CLEAR_DB" />
<action android:name="com.roundingmobile.notisaver.test.INJECT_NOTIFICATION" />
<action android:name="com.roundingmobile.notisaver.test.INJECT_BATCH" />
<action android:name="com.roundingmobile.notisaver.test.REMOVE_NOTIFICATION" />
<action android:name="com.roundingmobile.notisaver.test.TRIGGER_CLEANUP" />
<action android:name="com.roundingmobile.notisaver.test.TRIGGER_DIGEST" />
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -0,0 +1,17 @@
package com.roundingmobile.notisaver.di
import com.roundingmobile.notisaver.instrumentation.LiveTestInstrumentation
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class FlavorModule {
@Binds
@Singleton
abstract fun bindTestInstrumentation(impl: LiveTestInstrumentation): TestInstrumentation
}

View file

@ -0,0 +1,60 @@
package com.roundingmobile.notisaver.instrumentation
import android.util.Log
import androidx.compose.ui.geometry.Rect
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LiveTestInstrumentation @Inject constructor() : TestInstrumentation {
private val viewPositions = ConcurrentHashMap<String, ViewInfo>()
private val stateMap = ConcurrentHashMap<String, String>()
data class ViewInfo(
val id: String,
val type: String,
val bounds: Rect,
val visible: Boolean,
val text: String
)
override fun trackView(id: String, type: String, bounds: Rect, visible: Boolean, text: String) {
viewPositions[id] = ViewInfo(id, type, bounds, visible, text)
}
override fun reportState(key: String, value: String) {
stateMap[key] = value
}
override fun reportEvent(event: String, params: Map<String, String>) {
val paramsStr = params.entries.joinToString(" ") { "${it.key}=${it.value}" }
Log.v(TAG, "EVENT $event $paramsStr".trim())
}
override fun dumpUI() {
viewPositions.forEach { (_, info) ->
Log.v(
TAG,
"VIEW id=${info.id} type=${info.type} " +
"bounds=${info.bounds.left.toInt()},${info.bounds.top.toInt()}," +
"${info.bounds.right.toInt()},${info.bounds.bottom.toInt()} " +
"visible=${info.visible} text=\"${info.text}\""
)
}
if (viewPositions.isEmpty()) {
Log.v(TAG, "VIEW none_tracked")
}
}
override fun dumpState() {
val stateStr = stateMap.entries.joinToString(" ") { "${it.key}=${it.value}" }
Log.v(TAG, "STATE $stateStr".trim())
}
companion object {
const val TAG = "CLAUDE_TEST"
}
}

View file

@ -0,0 +1,180 @@
package com.roundingmobile.notisaver.instrumentation
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.roundingmobile.notisaver.domain.model.CapturedNotification
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import javax.inject.Inject
@AndroidEntryPoint
class TestBroadcastReceiver : BroadcastReceiver() {
@Inject lateinit var instrumentation: TestInstrumentation
@Inject lateinit var repository: NotificationRepository
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val json = Json { ignoreUnknownKeys = true }
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_DUMP_UI -> {
Log.v(TAG, "DUMP_UI requested")
instrumentation.dumpUI()
}
ACTION_DUMP_STATE -> {
scope.launch {
val count = repository.getCount()
Log.v(TAG, "STATE db_count=$count")
instrumentation.reportState("db_count", count.toString())
instrumentation.dumpState()
}
}
ACTION_INJECT_NOTIFICATION -> {
val pkg = intent.getStringExtra("package") ?: "com.unknown"
val appName = intent.getStringExtra("app_name") ?: pkg
val title = intent.getStringExtra("title") ?: ""
val text = intent.getStringExtra("text") ?: ""
val category = intent.getStringExtra("category")
val priority = intent.getIntExtra("priority", 0)
val now = System.currentTimeMillis()
val timestamp = intent.getLongExtra("timestamp", now)
scope.launch {
val notification = CapturedNotification(
id = 0,
packageName = pkg,
appName = appName,
title = title,
text = text,
bigText = null,
category = category,
priority = priority,
timestamp = timestamp,
notificationId = 0,
notificationTag = null,
isUpdate = false,
previousVersionId = null,
isRemoved = false,
removedAt = null,
removalDelayMs = null,
isBookmarked = false,
extrasJson = null,
actionsJson = null,
iconUri = null,
createdAt = timestamp
)
val id = repository.insert(notification)
Log.v(TAG, "EVENT inject_notification id=$id package=$pkg title=\"$title\"")
}
}
ACTION_INJECT_BATCH -> {
val jsonStr = intent.getStringExtra("json") ?: return
scope.launch {
try {
val items = json.decodeFromString<List<BatchItem>>(jsonStr)
val now = System.currentTimeMillis()
val notifications = items.mapIndexed { index, item ->
CapturedNotification(
id = 0,
packageName = item.packageName,
appName = item.appName ?: item.packageName,
title = item.title,
text = item.text,
bigText = null,
category = item.category,
priority = item.priority ?: 0,
timestamp = item.timestamp ?: (now + index),
notificationId = 0,
notificationTag = null,
isUpdate = false,
previousVersionId = null,
isRemoved = false,
removedAt = null,
removalDelayMs = null,
isBookmarked = false,
extrasJson = null,
actionsJson = null,
iconUri = null,
createdAt = item.timestamp ?: now
)
}
repository.insertAll(notifications)
Log.v(TAG, "EVENT inject_batch count=${notifications.size}")
} catch (e: Exception) {
Log.e(TAG, "ERROR type=inject_batch message=\"${e.message}\"")
}
}
}
ACTION_REMOVE_NOTIFICATION -> {
val id = intent.getIntExtra("notification_id", -1).toLong()
if (id > 0) {
scope.launch {
val now = System.currentTimeMillis()
repository.markRemoved(id, now, 1000L)
Log.v(TAG, "EVENT remove_notification id=$id")
}
}
}
ACTION_CLEAR_DB -> {
scope.launch {
repository.deleteAll()
Log.v(TAG, "EVENT clear_db")
}
}
ACTION_NAVIGATE -> {
val screen = intent.getStringExtra("screen") ?: return
Log.v(TAG, "EVENT navigate_request screen=$screen")
instrumentation.reportEvent("navigate_request", mapOf("screen" to screen))
}
ACTION_SET_STATE -> {
val key = intent.getStringExtra("key") ?: return
val value = intent.getStringExtra("value") ?: return
Log.v(TAG, "SET_STATE $key=$value")
instrumentation.reportState(key, value)
}
ACTION_TRIGGER_CLEANUP -> {
Log.v(TAG, "EVENT trigger_cleanup")
instrumentation.reportEvent("trigger_cleanup_request")
}
ACTION_TRIGGER_DIGEST -> {
Log.v(TAG, "EVENT trigger_digest")
instrumentation.reportEvent("trigger_digest_request")
}
}
}
@Serializable
private data class BatchItem(
@kotlinx.serialization.SerialName("package") val packageName: String,
@kotlinx.serialization.SerialName("app_name") val appName: String? = null,
val title: String? = null,
val text: String? = null,
val category: String? = null,
val priority: Int? = null,
val timestamp: Long? = null
)
companion object {
private const val TAG = "CLAUDE_TEST"
private const val PREFIX = "com.roundingmobile.notisaver.test."
const val ACTION_DUMP_UI = "${PREFIX}DUMP_UI"
const val ACTION_DUMP_STATE = "${PREFIX}DUMP_STATE"
const val ACTION_NAVIGATE = "${PREFIX}NAVIGATE"
const val ACTION_SET_STATE = "${PREFIX}SET_STATE"
const val ACTION_CLEAR_DB = "${PREFIX}CLEAR_DB"
const val ACTION_INJECT_NOTIFICATION = "${PREFIX}INJECT_NOTIFICATION"
const val ACTION_INJECT_BATCH = "${PREFIX}INJECT_BATCH"
const val ACTION_REMOVE_NOTIFICATION = "${PREFIX}REMOVE_NOTIFICATION"
const val ACTION_TRIGGER_CLEANUP = "${PREFIX}TRIGGER_CLEANUP"
const val ACTION_TRIGGER_DIGEST = "${PREFIX}TRIGGER_DIGEST"
}
}

View file

@ -0,0 +1,17 @@
package com.roundingmobile.notisaver.di
import com.roundingmobile.notisaver.instrumentation.NoOpTestInstrumentation
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object FlavorModule {
@Provides
@Singleton
fun provideTestInstrumentation(): TestInstrumentation = NoOpTestInstrumentation()
}

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:name=".App"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.NotificationSaver"
tools:targetApi="35">
<service
android:name=".service.NotificationCaptureService"
android:exported="true"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<activity
android:name=".presentation.MainActivity"
android:exported="true"
android:theme="@style/Theme.NotificationSaver">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,7 @@
package com.roundingmobile.notisaver
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class App : Application()

View file

@ -0,0 +1,42 @@
package com.roundingmobile.notisaver.data.di
import android.content.Context
import androidx.room.Room
import com.roundingmobile.notisaver.data.local.db.AppDatabase
import com.roundingmobile.notisaver.data.local.db.dao.AppFilterDao
import com.roundingmobile.notisaver.data.local.db.dao.FilterRuleDao
import com.roundingmobile.notisaver.data.local.db.dao.HiddenAppDao
import com.roundingmobile.notisaver.data.local.db.dao.NotificationDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"notisaver.db"
).fallbackToDestructiveMigration(dropAllTables = true).build()
}
@Provides
fun provideNotificationDao(db: AppDatabase): NotificationDao = db.notificationDao()
@Provides
fun provideHiddenAppDao(db: AppDatabase): HiddenAppDao = db.hiddenAppDao()
@Provides
fun provideAppFilterDao(db: AppDatabase): AppFilterDao = db.appFilterDao()
@Provides
fun provideFilterRuleDao(db: AppDatabase): FilterRuleDao = db.filterRuleDao()
}

View file

@ -0,0 +1,21 @@
package com.roundingmobile.notisaver.data.di
import android.content.Context
import android.content.SharedPreferences
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object PreferencesModule {
@Provides
@Singleton
fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
return context.getSharedPreferences("notisaver_prefs", Context.MODE_PRIVATE)
}
}

View file

@ -0,0 +1,23 @@
package com.roundingmobile.notisaver.data.di
import com.roundingmobile.notisaver.data.repository.FilterRuleRepositoryImpl
import com.roundingmobile.notisaver.data.repository.NotificationRepositoryImpl
import com.roundingmobile.notisaver.domain.repository.FilterRuleRepository
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindNotificationRepository(impl: NotificationRepositoryImpl): NotificationRepository
@Binds
@Singleton
abstract fun bindFilterRuleRepository(impl: FilterRuleRepositoryImpl): FilterRuleRepository
}

View file

@ -0,0 +1,33 @@
package com.roundingmobile.notisaver.data.local.db
import androidx.room.Database
import androidx.room.RoomDatabase
import com.roundingmobile.notisaver.data.local.db.dao.AppFilterDao
import com.roundingmobile.notisaver.data.local.db.dao.FilterRuleDao
import com.roundingmobile.notisaver.data.local.db.dao.HiddenAppDao
import com.roundingmobile.notisaver.data.local.db.dao.NotificationDao
import com.roundingmobile.notisaver.data.local.db.entity.AppFilterEntity
import com.roundingmobile.notisaver.data.local.db.entity.FilterRuleEntity
import com.roundingmobile.notisaver.data.local.db.entity.HiddenAppEntity
import com.roundingmobile.notisaver.data.local.db.entity.KeywordAlertEntity
import com.roundingmobile.notisaver.data.local.db.entity.NotificationEntity
import com.roundingmobile.notisaver.data.local.db.entity.NotificationFtsEntity
@Database(
entities = [
NotificationEntity::class,
NotificationFtsEntity::class,
HiddenAppEntity::class,
KeywordAlertEntity::class,
AppFilterEntity::class,
FilterRuleEntity::class
],
version = 2,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun notificationDao(): NotificationDao
abstract fun hiddenAppDao(): HiddenAppDao
abstract fun appFilterDao(): AppFilterDao
abstract fun filterRuleDao(): FilterRuleDao
}

View file

@ -0,0 +1,27 @@
package com.roundingmobile.notisaver.data.local.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.roundingmobile.notisaver.data.local.db.entity.AppFilterEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface AppFilterDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: AppFilterEntity)
@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>
@Query("DELETE FROM app_filter_prefs")
suspend fun deleteAll()
}

View file

@ -0,0 +1,40 @@
package com.roundingmobile.notisaver.data.local.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.roundingmobile.notisaver.data.local.db.entity.FilterRuleEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface FilterRuleDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: FilterRuleEntity): Long
@Update
suspend fun update(entity: FilterRuleEntity)
@Query("DELETE FROM filter_rules WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("SELECT * FROM filter_rules ORDER BY is_built_in DESC, created_at DESC")
fun getAllFlow(): Flow<List<FilterRuleEntity>>
@Query("SELECT * FROM filter_rules WHERE is_enabled = 1")
suspend fun getEnabledRules(): List<FilterRuleEntity>
@Query("SELECT * FROM filter_rules WHERE id = :id")
suspend fun getById(id: Long): FilterRuleEntity?
@Query("SELECT COUNT(*) FROM filter_rules")
suspend fun getCount(): Int
@Query("SELECT COUNT(*) FROM filter_rules WHERE is_enabled = 1")
fun getEnabledCountFlow(): Flow<Int>
@Query("UPDATE filter_rules SET is_enabled = :enabled WHERE id = :id")
suspend fun setEnabled(id: Long, enabled: Boolean)
}

View file

@ -0,0 +1,27 @@
package com.roundingmobile.notisaver.data.local.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.roundingmobile.notisaver.data.local.db.entity.HiddenAppEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface HiddenAppDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: HiddenAppEntity)
@Query("DELETE FROM hidden_apps WHERE package_name = :packageName")
suspend fun delete(packageName: String)
@Query("SELECT * FROM hidden_apps ORDER BY hidden_at DESC")
fun getAllFlow(): Flow<List<HiddenAppEntity>>
@Query("SELECT package_name FROM hidden_apps")
suspend fun getAllPackageNames(): List<String>
@Query("SELECT EXISTS(SELECT 1 FROM hidden_apps WHERE package_name = :packageName)")
suspend fun isHidden(packageName: String): Boolean
}

View file

@ -0,0 +1,124 @@
package com.roundingmobile.notisaver.data.local.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.roundingmobile.notisaver.data.local.db.entity.NotificationEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface NotificationDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: NotificationEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(entities: List<NotificationEntity>)
@Update
suspend fun update(entity: NotificationEntity)
@Query("SELECT * FROM notifications ORDER BY timestamp DESC")
fun getAllFlow(): Flow<List<NotificationEntity>>
@Query("SELECT * FROM notifications WHERE id = :id")
suspend fun getById(id: Long): NotificationEntity?
@Query("SELECT * FROM notifications WHERE id = :id")
fun getByIdFlow(id: Long): Flow<NotificationEntity?>
@Query(
"""SELECT * FROM notifications
WHERE package_name NOT IN (SELECT package_name FROM hidden_apps)
ORDER BY timestamp DESC"""
)
fun getVisibleFlow(): Flow<List<NotificationEntity>>
@Query(
"""SELECT * FROM notifications
WHERE package_name NOT IN (SELECT package_name FROM hidden_apps)
ORDER BY timestamp ASC"""
)
fun getVisibleOldestFirstFlow(): Flow<List<NotificationEntity>>
@Query(
"""SELECT * FROM notifications
WHERE package_name NOT IN (SELECT package_name FROM hidden_apps)
ORDER BY package_name ASC, timestamp DESC"""
)
fun getVisibleByAppFlow(): Flow<List<NotificationEntity>>
@Query(
"""SELECT * FROM notifications
WHERE title LIKE '%' || :query || '%'
OR text LIKE '%' || :query || '%'
OR big_text LIKE '%' || :query || '%'
OR app_name LIKE '%' || :query || '%'
OR package_name LIKE '%' || :query || '%'
ORDER BY timestamp DESC"""
)
fun search(query: String): Flow<List<NotificationEntity>>
@Query("SELECT * FROM notifications WHERE package_name = :packageName ORDER BY timestamp DESC")
fun getByPackageFlow(packageName: String): Flow<List<NotificationEntity>>
@Query(
"""SELECT * FROM notifications
WHERE package_name = :packageName AND title = :sender
ORDER BY timestamp ASC"""
)
fun getConversation(packageName: String, sender: String): Flow<List<NotificationEntity>>
@Query("UPDATE notifications SET is_bookmarked = :bookmarked WHERE id = :id")
suspend fun setBookmarked(id: Long, bookmarked: Boolean)
@Query("UPDATE notifications SET is_removed = 1, removed_at = :removedAt, removal_delay_ms = :delayMs WHERE id = :id")
suspend fun markRemoved(id: Long, removedAt: Long, delayMs: Long)
@Query(
"""SELECT * FROM notifications
WHERE notification_id = :notificationId AND notification_tag = :tag AND package_name = :packageName
ORDER BY timestamp DESC LIMIT 1"""
)
suspend fun findPrevious(notificationId: Int, tag: String?, packageName: String): NotificationEntity?
@Query(
"""SELECT * FROM notifications
WHERE package_name = :packageName AND title = :title AND timestamp > :since
ORDER BY timestamp DESC LIMIT 1"""
)
suspend fun findRecentByPackageAndTitle(packageName: String, title: String, since: Long): NotificationEntity?
@Query("SELECT COUNT(*) FROM notifications")
suspend fun getCount(): Int
@Query("SELECT COUNT(*) FROM notifications")
fun getCountFlow(): Flow<Int>
@Query("DELETE FROM notifications WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("DELETE FROM notifications WHERE timestamp < :before")
suspend fun deleteOlderThan(before: Long): Int
@Query("DELETE FROM notifications")
suspend fun deleteAll()
@Query("SELECT DISTINCT package_name, app_name FROM notifications ORDER BY app_name")
fun getDistinctApps(): Flow<List<AppNameTuple>>
@Query("SELECT package_name, COUNT(*) as count FROM notifications GROUP BY package_name ORDER BY count DESC")
fun getAppCounts(): Flow<List<AppCountTuple>>
}
data class AppNameTuple(
val package_name: String,
val app_name: String
)
data class AppCountTuple(
val package_name: String,
val count: Int
)

View file

@ -0,0 +1,11 @@
package com.roundingmobile.notisaver.data.local.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "app_filter_prefs")
data class AppFilterEntity(
@PrimaryKey @ColumnInfo(name = "package_name") val packageName: String,
@ColumnInfo(name = "is_visible") val isVisible: Boolean = true
)

View file

@ -0,0 +1,24 @@
package com.roundingmobile.notisaver.data.local.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "filter_rules",
indices = [Index(value = ["is_enabled"])]
)
data class FilterRuleEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val action: String,
@ColumnInfo(name = "match_field") val matchField: String,
@ColumnInfo(name = "match_type") val matchType: String,
val pattern: String,
@ColumnInfo(name = "package_name") val packageName: String? = null,
@ColumnInfo(name = "app_name") val appName: String? = null,
@ColumnInfo(name = "is_enabled") val isEnabled: Boolean = true,
@ColumnInfo(name = "is_built_in") val isBuiltIn: Boolean = false,
@ColumnInfo(name = "created_at") val createdAt: Long
)

View file

@ -0,0 +1,11 @@
package com.roundingmobile.notisaver.data.local.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "hidden_apps")
data class HiddenAppEntity(
@PrimaryKey @ColumnInfo(name = "package_name") val packageName: String,
@ColumnInfo(name = "hidden_at") val hiddenAt: Long
)

View file

@ -0,0 +1,14 @@
package com.roundingmobile.notisaver.data.local.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "keyword_alerts")
data class KeywordAlertEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val keyword: String,
@ColumnInfo(name = "is_regex") val isRegex: Boolean = false,
@ColumnInfo(name = "is_active") val isActive: Boolean = true,
@ColumnInfo(name = "created_at") val createdAt: Long
)

View file

@ -0,0 +1,40 @@
package com.roundingmobile.notisaver.data.local.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "notifications",
indices = [
Index(value = ["package_name"]),
Index(value = ["timestamp"]),
Index(value = ["category"]),
Index(value = ["is_bookmarked"]),
Index(value = ["package_name", "timestamp"])
]
)
data class NotificationEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "package_name") val packageName: String,
@ColumnInfo(name = "app_name") val appName: String,
val title: String?,
val text: String?,
@ColumnInfo(name = "big_text") val bigText: String?,
val category: String?,
val priority: Int = 0,
val timestamp: Long,
@ColumnInfo(name = "notification_id") val notificationId: Int = 0,
@ColumnInfo(name = "notification_tag") val notificationTag: String? = null,
@ColumnInfo(name = "is_update") val isUpdate: Boolean = false,
@ColumnInfo(name = "previous_version_id") val previousVersionId: Long? = null,
@ColumnInfo(name = "is_removed") val isRemoved: Boolean = false,
@ColumnInfo(name = "removed_at") val removedAt: Long? = null,
@ColumnInfo(name = "removal_delay_ms") val removalDelayMs: Long? = null,
@ColumnInfo(name = "is_bookmarked") val isBookmarked: Boolean = false,
@ColumnInfo(name = "extras_json") val extrasJson: String? = null,
@ColumnInfo(name = "actions_json") val actionsJson: String? = null,
@ColumnInfo(name = "icon_uri") val iconUri: String? = null,
@ColumnInfo(name = "created_at") val createdAt: Long
)

View file

@ -0,0 +1,12 @@
package com.roundingmobile.notisaver.data.local.db.entity
import androidx.room.Entity
import androidx.room.Fts4
@Fts4(contentEntity = NotificationEntity::class)
@Entity(tableName = "notifications_fts")
data class NotificationFtsEntity(
val title: String?,
val text: String?,
@androidx.room.ColumnInfo(name = "big_text") val bigText: String?
)

View file

@ -0,0 +1,35 @@
package com.roundingmobile.notisaver.data.mapper
import com.roundingmobile.notisaver.data.local.db.entity.FilterRuleEntity
import com.roundingmobile.notisaver.domain.model.FilterAction
import com.roundingmobile.notisaver.domain.model.FilterRule
import com.roundingmobile.notisaver.domain.model.MatchField
import com.roundingmobile.notisaver.domain.model.MatchType
fun FilterRuleEntity.toDomain(): FilterRule = FilterRule(
id = id,
name = name,
action = FilterAction.valueOf(action),
matchField = MatchField.valueOf(matchField),
matchType = MatchType.valueOf(matchType),
pattern = pattern,
packageName = packageName,
appName = appName,
isEnabled = isEnabled,
isBuiltIn = isBuiltIn,
createdAt = createdAt
)
fun FilterRule.toEntity(): FilterRuleEntity = FilterRuleEntity(
id = id,
name = name,
action = action.name,
matchField = matchField.name,
matchType = matchType.name,
pattern = pattern,
packageName = packageName,
appName = appName,
isEnabled = isEnabled,
isBuiltIn = isBuiltIn,
createdAt = createdAt
)

View file

@ -0,0 +1,52 @@
package com.roundingmobile.notisaver.data.mapper
import com.roundingmobile.notisaver.data.local.db.entity.NotificationEntity
import com.roundingmobile.notisaver.domain.model.CapturedNotification
fun NotificationEntity.toDomain(): CapturedNotification = CapturedNotification(
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,
extrasJson = extrasJson,
actionsJson = actionsJson,
iconUri = iconUri,
createdAt = createdAt
)
fun CapturedNotification.toEntity(): NotificationEntity = 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,
extrasJson = extrasJson,
actionsJson = actionsJson,
iconUri = iconUri,
createdAt = createdAt
)

View file

@ -0,0 +1,83 @@
package com.roundingmobile.notisaver.data.repository
import com.roundingmobile.notisaver.data.local.db.dao.FilterRuleDao
import com.roundingmobile.notisaver.data.mapper.toDomain
import com.roundingmobile.notisaver.data.mapper.toEntity
import com.roundingmobile.notisaver.domain.model.FilterAction
import com.roundingmobile.notisaver.domain.model.FilterRule
import com.roundingmobile.notisaver.domain.model.MatchField
import com.roundingmobile.notisaver.domain.model.MatchType
import com.roundingmobile.notisaver.domain.repository.FilterRuleRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FilterRuleRepositoryImpl @Inject constructor(
private val dao: FilterRuleDao
) : FilterRuleRepository {
override fun getAllRulesFlow(): Flow<List<FilterRule>> =
dao.getAllFlow().map { list -> list.map { it.toDomain() } }
override fun getEnabledCountFlow(): Flow<Int> = dao.getEnabledCountFlow()
override suspend fun getEnabledRules(): List<FilterRule> =
dao.getEnabledRules().map { it.toDomain() }
override suspend fun getById(id: Long): FilterRule? =
dao.getById(id)?.toDomain()
override suspend fun insert(rule: FilterRule): Long =
dao.insert(rule.toEntity())
override suspend fun update(rule: FilterRule) =
dao.update(rule.toEntity())
override suspend fun deleteById(id: Long) =
dao.deleteById(id)
override suspend fun setEnabled(id: Long, enabled: Boolean) =
dao.setEnabled(id, enabled)
override suspend fun seedPresetsIfNeeded() {
if (dao.getCount() > 0) return
val now = System.currentTimeMillis()
PRESETS.forEachIndexed { index, rule ->
dao.insert(rule.copy(createdAt = now - index).toEntity())
}
}
companion object {
private val PRESETS = listOf(
FilterRule(
name = "Delivery tracking",
action = FilterAction.SUPPRESS,
matchField = MatchField.TITLE_OR_TEXT,
matchType = MatchType.REGEX,
pattern = "(?i)(out for delivery|has been delivered|your package|shipment|tracking)",
isEnabled = false,
isBuiltIn = true
),
FilterRule(
name = "Promotional",
action = FilterAction.SUPPRESS,
matchField = MatchField.TITLE_OR_TEXT,
matchType = MatchType.REGEX,
pattern = "(?i)(% off|flash sale|limited time|promo code|special offer)",
isEnabled = false,
isBuiltIn = true
),
FilterRule(
name = "OTP / Verification codes",
action = FilterAction.LOW_PRIORITY,
matchField = MatchField.TEXT,
matchType = MatchType.REGEX,
pattern = "(?i)\\b\\d{4,8}\\b.*(code|otp|verify|verification)",
isEnabled = false,
isBuiltIn = true
)
)
}
}

View file

@ -0,0 +1,93 @@
package com.roundingmobile.notisaver.data.repository
import com.roundingmobile.notisaver.data.local.db.dao.NotificationDao
import com.roundingmobile.notisaver.data.mapper.toDomain
import com.roundingmobile.notisaver.data.mapper.toEntity
import com.roundingmobile.notisaver.domain.model.AppInfo
import com.roundingmobile.notisaver.domain.model.CapturedNotification
import com.roundingmobile.notisaver.domain.model.SortOrder
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NotificationRepositoryImpl @Inject constructor(
private val notificationDao: NotificationDao
) : NotificationRepository {
override fun getTimeline(sortOrder: SortOrder): Flow<List<CapturedNotification>> {
return when (sortOrder) {
SortOrder.NEWEST_FIRST -> notificationDao.getVisibleFlow()
SortOrder.OLDEST_FIRST -> notificationDao.getVisibleOldestFirstFlow()
SortOrder.BY_APP -> notificationDao.getVisibleByAppFlow()
}.map { list -> list.map { it.toDomain() } }
}
override fun getById(id: Long): Flow<CapturedNotification?> {
return notificationDao.getByIdFlow(id).map { it?.toDomain() }
}
override fun search(query: String): Flow<List<CapturedNotification>> {
return notificationDao.search(query).map { list -> list.map { it.toDomain() } }
}
override fun getByPackage(packageName: String): Flow<List<CapturedNotification>> {
return notificationDao.getByPackageFlow(packageName).map { list -> list.map { it.toDomain() } }
}
override fun getConversation(packageName: String, sender: String): Flow<List<CapturedNotification>> {
return notificationDao.getConversation(packageName, sender).map { list -> list.map { it.toDomain() } }
}
override fun getCountFlow(): Flow<Int> = notificationDao.getCountFlow()
override fun getDistinctApps(): Flow<List<AppInfo>> {
return notificationDao.getAppCounts().map { list ->
list.map { tuple ->
AppInfo(
packageName = tuple.package_name,
appName = tuple.package_name, // Will be enriched when we have app_name from counts
notificationCount = tuple.count
)
}
}
}
override suspend fun insert(notification: CapturedNotification): Long {
return notificationDao.insert(notification.toEntity())
}
override suspend fun insertAll(notifications: List<CapturedNotification>) {
notificationDao.insertAll(notifications.map { it.toEntity() })
}
override suspend fun setBookmarked(id: Long, bookmarked: Boolean) {
notificationDao.setBookmarked(id, bookmarked)
}
override suspend fun markRemoved(id: Long, removedAt: Long, delayMs: Long) {
notificationDao.markRemoved(id, removedAt, delayMs)
}
override suspend fun findPrevious(notificationId: Int, tag: String?, packageName: String): CapturedNotification? {
return notificationDao.findPrevious(notificationId, tag, packageName)?.toDomain()
}
override suspend fun findRecentByPackageAndTitle(packageName: String, title: String, sinceMs: Long): CapturedNotification? {
return notificationDao.findRecentByPackageAndTitle(packageName, title, sinceMs)?.toDomain()
}
override suspend fun update(notification: CapturedNotification) {
notificationDao.update(notification.toEntity())
}
override suspend fun getCount(): Int = notificationDao.getCount()
override suspend fun deleteById(id: Long) = notificationDao.deleteById(id)
override suspend fun deleteOlderThan(before: Long): Int = notificationDao.deleteOlderThan(before)
override suspend fun deleteAll() = notificationDao.deleteAll()
}

View file

@ -0,0 +1,7 @@
package com.roundingmobile.notisaver.domain.model
data class AppInfo(
val packageName: String,
val appName: String,
val notificationCount: Int = 0
)

View file

@ -0,0 +1,32 @@
package com.roundingmobile.notisaver.domain.model
data class CapturedNotification(
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 extrasJson: String?,
val actionsJson: String?,
val iconUri: String?,
val createdAt: Long
) {
val isPossiblyDeleted: Boolean
get() = isRemoved && removalDelayMs != null && removalDelayMs < DELETED_THRESHOLD_MS
companion object {
const val DELETED_THRESHOLD_MS = 5000L
}
}

View file

@ -0,0 +1,8 @@
package com.roundingmobile.notisaver.domain.model
enum class ExportFormat {
CSV,
JSON,
TXT,
PDF
}

View file

@ -0,0 +1,34 @@
package com.roundingmobile.notisaver.domain.model
data class FilterRule(
val id: Long = 0,
val name: String,
val action: FilterAction,
val matchField: MatchField,
val matchType: MatchType,
val pattern: String,
val packageName: String? = null,
val appName: String? = null,
val isEnabled: Boolean = true,
val isBuiltIn: Boolean = false,
val createdAt: Long = 0
)
enum class FilterAction {
SUPPRESS,
LOW_PRIORITY
}
enum class MatchField {
TITLE,
TEXT,
TITLE_OR_TEXT,
APP_NAME,
PACKAGE_NAME
}
enum class MatchType {
CONTAINS,
EXACT,
REGEX
}

View file

@ -0,0 +1,7 @@
package com.roundingmobile.notisaver.domain.model
enum class SortOrder {
NEWEST_FIRST,
OLDEST_FIRST,
BY_APP
}

View file

@ -0,0 +1,16 @@
package com.roundingmobile.notisaver.domain.repository
import com.roundingmobile.notisaver.domain.model.FilterRule
import kotlinx.coroutines.flow.Flow
interface FilterRuleRepository {
fun getAllRulesFlow(): Flow<List<FilterRule>>
fun getEnabledCountFlow(): Flow<Int>
suspend fun getEnabledRules(): List<FilterRule>
suspend fun getById(id: Long): FilterRule?
suspend fun insert(rule: FilterRule): Long
suspend fun update(rule: FilterRule)
suspend fun deleteById(id: Long)
suspend fun setEnabled(id: Long, enabled: Boolean)
suspend fun seedPresetsIfNeeded()
}

View file

@ -0,0 +1,28 @@
package com.roundingmobile.notisaver.domain.repository
import com.roundingmobile.notisaver.domain.model.AppInfo
import com.roundingmobile.notisaver.domain.model.CapturedNotification
import com.roundingmobile.notisaver.domain.model.SortOrder
import kotlinx.coroutines.flow.Flow
interface NotificationRepository {
fun getTimeline(sortOrder: SortOrder): Flow<List<CapturedNotification>>
fun getById(id: Long): Flow<CapturedNotification?>
fun search(query: String): Flow<List<CapturedNotification>>
fun getByPackage(packageName: String): Flow<List<CapturedNotification>>
fun getConversation(packageName: String, sender: String): Flow<List<CapturedNotification>>
fun getCountFlow(): Flow<Int>
fun getDistinctApps(): Flow<List<AppInfo>>
suspend fun insert(notification: CapturedNotification): Long
suspend fun insertAll(notifications: List<CapturedNotification>)
suspend fun setBookmarked(id: Long, bookmarked: Boolean)
suspend fun markRemoved(id: Long, removedAt: Long, delayMs: Long)
suspend fun findPrevious(notificationId: Int, tag: String?, packageName: String): CapturedNotification?
suspend fun findRecentByPackageAndTitle(packageName: String, title: String, sinceMs: Long): CapturedNotification?
suspend fun update(notification: CapturedNotification)
suspend fun getCount(): Int
suspend fun deleteById(id: Long)
suspend fun deleteOlderThan(before: Long): Int
suspend fun deleteAll()
}

View file

@ -0,0 +1,28 @@
package com.roundingmobile.notisaver.domain.usecase
import com.roundingmobile.notisaver.domain.model.AppInfo
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
class ComputeStatisticsUseCase @Inject constructor(
private val repository: NotificationRepository
) {
operator fun invoke(): Flow<StatsData> {
return combine(
repository.getCountFlow(),
repository.getDistinctApps()
) { totalCount, apps ->
StatsData(
totalCount = totalCount,
appStats = apps.sortedByDescending { it.notificationCount }
)
}
}
}
data class StatsData(
val totalCount: Int,
val appStats: List<AppInfo>
)

View file

@ -0,0 +1,55 @@
package com.roundingmobile.notisaver.domain.usecase
import com.roundingmobile.notisaver.domain.model.CapturedNotification
import com.roundingmobile.notisaver.domain.model.ExportFormat
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
import kotlinx.coroutines.flow.first
import javax.inject.Inject
class ExportNotificationsUseCase @Inject constructor(
private val repository: NotificationRepository
) {
suspend operator fun invoke(format: ExportFormat): String {
val notifications = repository.getTimeline(
com.roundingmobile.notisaver.domain.model.SortOrder.NEWEST_FIRST
).first()
return when (format) {
ExportFormat.CSV -> toCsv(notifications)
ExportFormat.JSON -> toJson(notifications)
ExportFormat.TXT -> toTxt(notifications)
ExportFormat.PDF -> toTxt(notifications) // PDF uses text as content
}
}
private fun toCsv(notifications: List<CapturedNotification>): String = buildString {
appendLine("id,package,app_name,title,text,timestamp,category,priority,is_bookmarked")
notifications.forEach { n ->
appendLine("${n.id},\"${n.packageName}\",\"${n.appName}\",\"${n.title ?: ""}\",\"${n.text ?: ""}\",${n.timestamp},\"${n.category ?: ""}\",${n.priority},${n.isBookmarked}")
}
}
private fun toJson(notifications: List<CapturedNotification>): String = buildString {
appendLine("[")
notifications.forEachIndexed { index, n ->
append(" {")
append("\"id\":${n.id},")
append("\"package\":\"${n.packageName}\",")
append("\"app_name\":\"${n.appName}\",")
append("\"title\":\"${n.title ?: ""}\",")
append("\"text\":\"${n.text ?: ""}\",")
append("\"timestamp\":${n.timestamp},")
append("\"category\":\"${n.category ?: ""}\"")
append("}")
if (index < notifications.size - 1) appendLine(",") else appendLine()
}
appendLine("]")
}
private fun toTxt(notifications: List<CapturedNotification>): String = buildString {
notifications.forEach { n ->
appendLine("[${n.appName}] ${n.title ?: ""}")
n.text?.let { appendLine(it) }
appendLine("---")
}
}
}

View file

@ -0,0 +1,12 @@
package com.roundingmobile.notisaver.domain.usecase
import com.roundingmobile.notisaver.domain.model.AppInfo
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class FilterByAppUseCase @Inject constructor(
private val repository: NotificationRepository
) {
fun getApps(): Flow<List<AppInfo>> = repository.getDistinctApps()
}

View file

@ -0,0 +1,56 @@
package com.roundingmobile.notisaver.domain.usecase
import com.roundingmobile.notisaver.domain.model.FilterAction
import com.roundingmobile.notisaver.domain.model.FilterRule
import com.roundingmobile.notisaver.domain.model.MatchField
import com.roundingmobile.notisaver.domain.model.MatchType
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FilterRuleEngine @Inject constructor() {
fun evaluate(
rules: List<FilterRule>,
packageName: String,
appName: String,
title: String?,
text: String?
): FilterAction? {
for (rule in rules) {
if (!rule.isEnabled) continue
if (rule.packageName != null && rule.packageName != packageName) continue
if (matchesContent(rule, packageName, appName, title, text)) {
return rule.action
}
}
return null
}
private fun matchesContent(
rule: FilterRule,
packageName: String,
appName: String,
title: String?,
text: String?
): Boolean {
val fields = when (rule.matchField) {
MatchField.TITLE -> listOfNotNull(title)
MatchField.TEXT -> listOfNotNull(text)
MatchField.TITLE_OR_TEXT -> listOfNotNull(title, text)
MatchField.APP_NAME -> listOf(appName)
MatchField.PACKAGE_NAME -> listOf(packageName)
}
return fields.any { matchesPattern(rule.matchType, rule.pattern, it) }
}
private fun matchesPattern(type: MatchType, pattern: String, value: String): Boolean = when (type) {
MatchType.CONTAINS -> value.contains(pattern, ignoreCase = true)
MatchType.EXACT -> value.equals(pattern, ignoreCase = true)
MatchType.REGEX -> try {
Regex(pattern).containsMatchIn(value)
} catch (_: Exception) {
false
}
}
}

View file

@ -0,0 +1,14 @@
package com.roundingmobile.notisaver.domain.usecase
import com.roundingmobile.notisaver.domain.model.CapturedNotification
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class GetNotificationDetailUseCase @Inject constructor(
private val repository: NotificationRepository
) {
operator fun invoke(id: Long): Flow<CapturedNotification?> {
return repository.getById(id)
}
}

View file

@ -0,0 +1,15 @@
package com.roundingmobile.notisaver.domain.usecase
import com.roundingmobile.notisaver.domain.model.CapturedNotification
import com.roundingmobile.notisaver.domain.model.SortOrder
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class GetTimelineUseCase @Inject constructor(
private val repository: NotificationRepository
) {
operator fun invoke(sortOrder: SortOrder = SortOrder.NEWEST_FIRST): Flow<List<CapturedNotification>> {
return repository.getTimeline(sortOrder)
}
}

View file

@ -0,0 +1,14 @@
package com.roundingmobile.notisaver.domain.usecase
import com.roundingmobile.notisaver.domain.model.CapturedNotification
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class SearchNotificationsUseCase @Inject constructor(
private val repository: NotificationRepository
) {
operator fun invoke(query: String): Flow<List<CapturedNotification>> {
return repository.search(query)
}
}

View file

@ -0,0 +1,29 @@
package com.roundingmobile.notisaver.instrumentation
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
interface TestInstrumentation {
fun trackView(id: String, type: String, bounds: Rect, visible: Boolean, text: String)
fun reportState(key: String, value: String)
fun reportEvent(event: String, params: Map<String, String> = emptyMap())
fun dumpUI()
fun dumpState()
}
class NoOpTestInstrumentation : TestInstrumentation {
override fun trackView(id: String, type: String, bounds: Rect, visible: Boolean, text: String) {}
override fun reportState(key: String, value: String) {}
override fun reportEvent(event: String, params: Map<String, String>) {}
override fun dumpUI() {}
override fun dumpState() {}
}
fun Modifier.testTrack(
instrumentation: TestInstrumentation?,
id: String,
text: String = ""
): Modifier {
if (instrumentation == null || instrumentation is NoOpTestInstrumentation) return this
return this.then(TestTrackModifier(instrumentation, id, text))
}

View file

@ -0,0 +1,40 @@
package com.roundingmobile.notisaver.instrumentation
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.node.GlobalPositionAwareModifierNode
import androidx.compose.ui.node.ModifierNodeElement
internal data class TestTrackModifier(
private val instrumentation: TestInstrumentation,
private val id: String,
private val text: String
) : ModifierNodeElement<TestTrackNode>() {
override fun create() = TestTrackNode(instrumentation, id, text)
override fun update(node: TestTrackNode) {
node.instrumentation = instrumentation
node.id = id
node.text = text
}
}
internal class TestTrackNode(
var instrumentation: TestInstrumentation,
var id: String,
var text: String
) : Modifier.Node(), GlobalPositionAwareModifierNode {
override fun onGloballyPositioned(coordinates: LayoutCoordinates) {
if (coordinates.isAttached) {
val bounds = coordinates.boundsInRoot()
instrumentation.trackView(
id = id,
type = "Composable",
bounds = bounds,
visible = coordinates.size.width > 0 && coordinates.size.height > 0,
text = text
)
}
}
}

View file

@ -0,0 +1,310 @@
package com.roundingmobile.notisaver.presentation
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.provider.Settings
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Timeline
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.unit.dp
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.roundingmobile.notisaver.R
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
import com.roundingmobile.notisaver.instrumentation.testTrack
import com.roundingmobile.notisaver.presentation.navigation.AppNavGraph
import com.roundingmobile.notisaver.presentation.navigation.Route
import com.roundingmobile.notisaver.presentation.onboarding.PermissionScreen
import com.roundingmobile.notisaver.presentation.theme.AppThemeType
import com.roundingmobile.notisaver.presentation.theme.NotiSaverTheme
import com.roundingmobile.notisaver.security.AppLockManager
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : FragmentActivity() {
@Inject lateinit var instrumentation: TestInstrumentation
@Inject lateinit var prefs: SharedPreferences
@Inject lateinit var lockManager: AppLockManager
private var currentTheme by mutableStateOf(AppThemeType.SYSTEM)
private var isLocked by mutableStateOf(false)
private var hasNotificationAccess by mutableStateOf(false)
private var everGranted: Boolean
get() = prefs.getBoolean("permission_ever_granted", false)
set(value) = prefs.edit().putBoolean("permission_ever_granted", value).apply()
private val prefsListener = SharedPreferences.OnSharedPreferenceChangeListener { sp, key ->
if (key == "app_theme") {
val name = sp.getString(key, AppThemeType.SYSTEM.name) ?: AppThemeType.SYSTEM.name
currentTheme = runCatching { AppThemeType.valueOf(name) }.getOrDefault(AppThemeType.SYSTEM)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val themeName = prefs.getString("app_theme", AppThemeType.SYSTEM.name) ?: AppThemeType.SYSTEM.name
currentTheme = runCatching { AppThemeType.valueOf(themeName) }.getOrDefault(AppThemeType.SYSTEM)
prefs.registerOnSharedPreferenceChangeListener(prefsListener)
hasNotificationAccess = checkNotificationAccess()
if (hasNotificationAccess) everGranted = true
if (lockManager.shouldLock()) isLocked = true
setContent {
NotiSaverTheme(themeType = currentTheme) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
when {
isLocked -> {
// Trigger biometric prompt immediately
androidx.compose.runtime.LaunchedEffect(Unit) {
showBiometricPrompt()
}
// Show a blank locked surface while prompt is up
LockSurface(onUnlock = { showBiometricPrompt() })
}
!hasNotificationAccess && !everGranted -> PermissionScreen()
else -> MainScreen(
instrumentation = instrumentation,
prefs = prefs,
permissionMissing = !hasNotificationAccess
)
}
}
}
}
}
override fun onResume() {
super.onResume()
val access = checkNotificationAccess()
hasNotificationAccess = access
if (access) everGranted = true
}
override fun onStart() {
super.onStart()
if (lockManager.shouldLock()) {
isLocked = true
}
}
override fun onStop() {
super.onStop()
lockManager.onBackground()
prefs.edit().putLong("last_seen_timestamp", System.currentTimeMillis()).apply()
}
override fun onDestroy() {
super.onDestroy()
prefs.unregisterOnSharedPreferenceChangeListener(prefsListener)
}
private fun checkNotificationAccess(): Boolean {
val packages = NotificationManagerCompat.getEnabledListenerPackages(this)
return packages.contains(packageName)
}
fun showBiometricPrompt() {
val executor = ContextCompat.getMainExecutor(this)
val prompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
isLocked = false
lockManager.onUnlocked()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
// User cancelled or no credentials — stay locked
}
override fun onAuthenticationFailed() {}
})
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock NotiSaver")
.setSubtitle("Verify your identity to continue")
.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
.build()
prompt.authenticate(info)
}
}
@Composable
private fun LockSurface(onUnlock: () -> Unit) {
Surface(
modifier = Modifier.fillMaxSize().clickable { onUnlock() },
color = MaterialTheme.colorScheme.background
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
androidx.compose.foundation.layout.Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.padding(8.dp))
Text(
"Tap to unlock",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// --- Bottom nav items ---
private data class BottomNavItem(
val route: Route,
val testId: String,
val labelRes: Int,
val icon: ImageVector,
val contentDescRes: Int
)
private val bottomNavItems = listOf(
BottomNavItem(Route.Timeline, "bottom_nav_timeline", R.string.nav_timeline, Icons.Default.Timeline, R.string.cd_timeline),
BottomNavItem(Route.Stats, "bottom_nav_stats", R.string.nav_stats, Icons.Default.BarChart, R.string.cd_stats),
BottomNavItem(Route.Settings, "bottom_nav_settings", R.string.nav_settings, Icons.Default.Settings, R.string.cd_settings)
)
// --- Main screen with nav ---
@Composable
private fun MainScreen(
instrumentation: TestInstrumentation,
prefs: SharedPreferences,
permissionMissing: Boolean
) {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val context = LocalContext.current
val showBottomBar = bottomNavItems.any { item ->
currentDestination?.hasRoute(item.route::class) == true
}
Scaffold(
contentWindowInsets = WindowInsets(0),
bottomBar = {
if (showBottomBar) {
NavigationBar(
modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f)
) {
bottomNavItems.forEach { item ->
val selected = currentDestination?.hasRoute(item.route::class) == true
NavigationBarItem(
modifier = Modifier.testTrack(instrumentation, item.testId, stringResource(item.labelRes)),
selected = selected,
onClick = {
navController.navigate(item.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(imageVector = item.icon, contentDescription = stringResource(item.contentDescRes)) },
label = { Text(stringResource(item.labelRes)) }
)
}
}
}
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding())
) {
// Permission revoked banner
AnimatedVisibility(visible = permissionMissing) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable {
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
},
color = MaterialTheme.colorScheme.errorContainer
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Notification access disabled — tap to re-enable",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
AppNavGraph(
navController = navController,
instrumentation = instrumentation,
modifier = Modifier.fillMaxSize()
)
}
}
}

View file

@ -0,0 +1,103 @@
package com.roundingmobile.notisaver.presentation.common
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun AppIcon(
packageName: String,
appName: String,
size: Dp = 40.dp,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val imageBitmap = remember(packageName) {
try {
val drawable = context.packageManager.getApplicationIcon(packageName)
drawableToBitmap(drawable)?.asImageBitmap()
} catch (_: Exception) {
null
}
}
if (imageBitmap != null) {
Image(
bitmap = imageBitmap,
contentDescription = "$appName icon",
contentScale = ContentScale.Crop,
modifier = modifier
.size(size)
.clip(CircleShape)
)
} else {
LetterIcon(
letter = appName.firstOrNull()?.uppercase() ?: "?",
color = colorForPackage(packageName),
size = size,
modifier = modifier
)
}
}
@Composable
fun LetterIcon(
letter: String,
color: Color,
size: Dp = 40.dp,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(color),
contentAlignment = Alignment.Center
) {
Text(
text = letter,
color = Color.White,
fontSize = (size.value * 0.45f).sp,
fontWeight = FontWeight.Bold
)
}
}
private fun drawableToBitmap(drawable: Drawable): Bitmap? {
val w = drawable.intrinsicWidth.coerceAtLeast(1)
val h = drawable.intrinsicHeight.coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, w, h)
drawable.draw(canvas)
return bitmap
}
private fun colorForPackage(packageName: String): Color {
val colors = listOf(
Color(0xFF4CAF50), Color(0xFF2196F3), Color(0xFFFF9800),
Color(0xFF9C27B0), Color(0xFFE91E63), Color(0xFF00BCD4),
Color(0xFF795548), Color(0xFF607D8B), Color(0xFFFF5722),
Color(0xFF3F51B5)
)
return colors[kotlin.math.abs(packageName.hashCode()) % colors.size]
}

View file

@ -0,0 +1,44 @@
package com.roundingmobile.notisaver.presentation.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationsNone
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.roundingmobile.notisaver.R
@Composable
fun EmptyState(
message: String = stringResource(R.string.empty_timeline),
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.NotificationsNone,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View file

@ -0,0 +1,183 @@
package com.roundingmobile.notisaver.presentation.common
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.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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.roundingmobile.notisaver.domain.model.CapturedNotification
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun NotificationItem(
notification: CapturedNotification,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
val categoryColor = categoryColor(notification.category)
val cardModifier = modifier.fillMaxWidth()
val cardShape = RoundedCornerShape(12.dp)
val cardElevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
if (onClick != null) {
Card(
modifier = cardModifier,
shape = cardShape,
elevation = cardElevation,
onClick = onClick
) { NotificationItemContent(notification, categoryColor) }
} else {
Card(
modifier = cardModifier,
shape = cardShape,
elevation = cardElevation
) { NotificationItemContent(notification, categoryColor) }
}
}
@Composable
private fun NotificationItemContent(
notification: CapturedNotification,
categoryColor: Color
) {
Row(modifier = Modifier.height(intrinsicSize = androidx.compose.foundation.layout.IntrinsicSize.Min)) {
Box(
modifier = Modifier
.width(4.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(topStart = 12.dp, bottomStart = 12.dp))
.background(categoryColor)
)
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppIcon(
packageName = notification.packageName,
appName = notification.appName,
size = 40.dp
)
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = notification.appName,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
Text(
text = formatTime(notification.timestamp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (!notification.title.isNullOrBlank()) {
Text(
text = notification.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (!notification.text.isNullOrBlank()) {
Text(
text = notification.text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
if (notification.isBookmarked || notification.isPossiblyDeleted) {
Spacer(modifier = Modifier.width(8.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (notification.isBookmarked) {
Icon(
imageVector = Icons.Default.Bookmark,
contentDescription = "Bookmarked",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
)
}
if (notification.isPossiblyDeleted) {
Icon(
imageVector = Icons.Default.DeleteSweep,
contentDescription = "Possibly deleted",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}
private fun categoryColor(category: String?): Color = when (category) {
"msg", "message" -> Color(0xFF4CAF50)
"email" -> Color(0xFF2196F3)
"social" -> Color(0xFFFF9800)
"call" -> Color(0xFF9C27B0)
"promo" -> Color(0xFFFF5722)
"sys", "system" -> Color(0xFF9E9E9E)
else -> Color(0xFFBDBDBD)
}
private val timeFormat = SimpleDateFormat("h:mm a", Locale.getDefault())
private val dateFormat = SimpleDateFormat("MMM d", Locale.getDefault())
private fun formatTime(timestamp: Long): String {
val now = System.currentTimeMillis()
val diff = now - timestamp
val oneDay = 24 * 60 * 60 * 1000L
return if (diff < oneDay) timeFormat.format(Date(timestamp))
else dateFormat.format(Date(timestamp))
}
fun formatTimeShort(timestamp: Long): String = timeFormat.format(Date(timestamp))

View file

@ -0,0 +1,37 @@
package com.roundingmobile.notisaver.presentation.common
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun SeenDivider(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
HorizontalDivider(
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.outlineVariant
)
Text(
text = "Already seen",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp)
)
HorizontalDivider(
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.outlineVariant
)
}
}

View file

@ -0,0 +1,235 @@
package com.roundingmobile.notisaver.presentation.common
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.BookmarkBorder
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.roundingmobile.notisaver.R
import com.roundingmobile.notisaver.domain.model.CapturedNotification
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun SwipeableNotificationItem(
notification: CapturedNotification,
onClick: () -> Unit,
onDelete: () -> Unit,
onBookmark: (Boolean) -> Unit,
onCreateRule: () -> Unit = {},
modifier: Modifier = Modifier
) {
val context = LocalContext.current
var showMenu by remember { mutableStateOf(false) }
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
onDelete()
true
} else false
}
)
val swipeProgress by remember {
derivedStateOf {
val offset = dismissState.progress
if (dismissState.targetValue == SwipeToDismissBoxValue.Settled) 0f
else (1f - offset).coerceIn(0f, 1f)
}
}
val iconAlpha by animateFloatAsState(
targetValue = if (swipeProgress > 0.05f) 1f else 0f,
animationSpec = tween(durationMillis = 150),
label = "deleteIconAlpha"
)
val iconScale by animateFloatAsState(
targetValue = if (swipeProgress > 0.05f) 0.8f + (swipeProgress * 0.2f).coerceAtMost(0.2f) else 0.6f,
animationSpec = tween(durationMillis = 150),
label = "deleteIconScale"
)
val errorContainer = MaterialTheme.colorScheme.errorContainer
val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
val bgColor = lerp(surfaceVariant, errorContainer, swipeProgress.coerceIn(0f, 1f))
val iconColor = MaterialTheme.colorScheme.onErrorContainer
Box(modifier = modifier) {
SwipeToDismissBox(
state = dismissState,
backgroundContent = {
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 2.dp)
.clip(RoundedCornerShape(12.dp))
.background(bgColor),
contentAlignment = Alignment.CenterEnd
) {
Row(
modifier = Modifier
.padding(horizontal = 20.dp)
.alpha(iconAlpha)
.scale(iconScale),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Delete",
color = iconColor,
style = MaterialTheme.typography.labelLarge
)
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = iconColor,
modifier = Modifier.size(24.dp)
)
}
}
},
enableDismissFromStartToEnd = false,
enableDismissFromEndToStart = true
) {
Box(
modifier = Modifier.combinedClickable(
onClick = onClick,
onLongClick = { showMenu = true }
)
) {
NotificationItem(
notification = notification,
onClick = null
)
// Context menu
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text("Delete") },
leadingIcon = { Icon(Icons.Default.Delete, null) },
onClick = { showMenu = false; onDelete() }
)
DropdownMenuItem(
text = { Text("Copy text") },
leadingIcon = { Icon(Icons.Default.ContentCopy, null) },
onClick = {
showMenu = false
copyNotification(context, notification)
}
)
DropdownMenuItem(
text = { Text("Share") },
leadingIcon = { Icon(Icons.Default.Share, null) },
onClick = {
showMenu = false
shareNotification(context, notification)
}
)
DropdownMenuItem(
text = { Text(if (notification.isBookmarked) "Remove bookmark" else "Bookmark") },
leadingIcon = {
Icon(
if (notification.isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
null
)
},
onClick = {
showMenu = false
onBookmark(!notification.isBookmarked)
}
)
DropdownMenuItem(
text = { Text("Open in app") },
leadingIcon = { Icon(Icons.Default.OpenInNew, null) },
onClick = {
showMenu = false
openApp(context, notification.packageName)
}
)
DropdownMenuItem(
text = { Text("Create filter rule") },
leadingIcon = { Icon(Icons.Default.FilterAlt, null) },
onClick = {
showMenu = false
onCreateRule()
}
)
}
}
}
}
}
private fun copyNotification(context: Context, n: CapturedNotification) {
val text = buildString {
n.title?.let { appendLine(it) }
n.text?.let { appendLine(it) }
}
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("notification", text))
Toast.makeText(context, R.string.detail_copied, Toast.LENGTH_SHORT).show()
}
private fun shareNotification(context: Context, n: CapturedNotification) {
val text = buildString {
appendLine("[${n.appName}]")
n.title?.let { appendLine(it) }
n.text?.let { appendLine(it) }
}
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(intent, null))
}
private fun openApp(context: Context, packageName: String) {
val intent = context.packageManager.getLaunchIntentForPackage(packageName)
if (intent != null) context.startActivity(intent)
}

View file

@ -0,0 +1,18 @@
package com.roundingmobile.notisaver.presentation.common
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun TimeBlockHeader(label: String, modifier: Modifier = Modifier) {
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}

View file

@ -0,0 +1,347 @@
package com.roundingmobile.notisaver.presentation.detail
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.widget.Toast
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.Bookmark
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
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.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.roundingmobile.notisaver.R
import com.roundingmobile.notisaver.domain.model.CapturedNotification
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
import com.roundingmobile.notisaver.instrumentation.testTrack
import com.roundingmobile.notisaver.presentation.common.AppIcon
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DetailScreen(
onBack: () -> Unit,
instrumentation: TestInstrumentation,
modifier: Modifier = Modifier,
viewModel: DetailViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.detail_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
}
)
},
modifier = modifier
) { innerPadding ->
when (val state = uiState) {
is DetailUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
}
is DetailUiState.NotFound -> {
Box(
modifier = Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center
) { Text("Notification not found") }
}
is DetailUiState.Success -> {
DetailContent(
notification = state.notification,
onToggleBookmark = { viewModel.toggleBookmark() },
instrumentation = instrumentation,
context = context,
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun DetailContent(
notification: CapturedNotification,
onToggleBookmark: () -> Unit,
instrumentation: TestInstrumentation,
context: Context,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
.testTrack(instrumentation, "detail_content", "id=${notification.id}")
) {
// App header with large icon
Row(verticalAlignment = Alignment.CenterVertically) {
AppIcon(
packageName = notification.packageName,
appName = notification.appName,
size = 56.dp
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(notification.appName, style = MaterialTheme.typography.titleLarge)
Text(
notification.packageName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(20.dp))
// Possibly deleted badge
if (notification.isPossiblyDeleted) {
Card(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.detail_possibly_deleted),
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelLarge
)
}
Spacer(modifier = Modifier.height(12.dp))
}
// Title
if (!notification.title.isNullOrBlank()) {
Text(notification.title, style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(8.dp))
}
// Text
if (!notification.text.isNullOrBlank()) {
Text(notification.text, style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.height(8.dp))
}
// Big text
if (!notification.bigText.isNullOrBlank() && notification.bigText != notification.text) {
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Text(notification.bigText, style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(8.dp))
}
// Metadata chips
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
AssistChip(
onClick = {},
label = { Text(notification.category ?: "Unknown") },
leadingIcon = null
)
AssistChip(onClick = {}, label = { Text("Priority: ${notification.priority}") })
AssistChip(onClick = {}, label = { Text("ID: ${notification.notificationId}") })
if (notification.isUpdate) {
AssistChip(onClick = {}, label = { Text("Update of #${notification.previousVersionId}") })
}
}
// Action buttons
Spacer(modifier = Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilledTonalButton(
onClick = { copyToClipboard(context, notification) },
modifier = Modifier
.weight(1f)
.testTrack(instrumentation, "btn_copy", "Copy")
) {
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
Text("Copy")
}
FilledTonalButton(
onClick = { shareNotification(context, notification) },
modifier = Modifier
.weight(1f)
.testTrack(instrumentation, "btn_share", "Share")
) {
Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
Text("Share")
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilledTonalButton(
onClick = onToggleBookmark,
modifier = Modifier
.weight(1f)
.testTrack(instrumentation, "btn_bookmark", "Bookmark")
) {
Icon(
if (notification.isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
contentDescription = null,
modifier = Modifier.padding(end = 4.dp)
)
Text(if (notification.isBookmarked) "Bookmarked" else "Bookmark")
}
FilledTonalButton(
onClick = { openSourceApp(context, notification.packageName) },
modifier = Modifier
.weight(1f)
.testTrack(instrumentation, "btn_open_app", "Open")
) {
Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
Text("Open App")
}
}
// Actions from notification
if (!notification.actionsJson.isNullOrBlank()) {
val actions = remember(notification.actionsJson) {
runCatching { Json.decodeFromString<List<String>>(notification.actionsJson) }
.getOrDefault(emptyList())
}
if (actions.isNotEmpty()) {
Spacer(modifier = Modifier.height(20.dp))
Text(stringResource(R.string.detail_actions), style = MaterialTheme.typography.titleSmall)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 4.dp)
) {
actions.forEach { action ->
AssistChip(onClick = {}, label = { Text(action) })
}
}
}
}
// Extras (collapsible)
if (!notification.extrasJson.isNullOrBlank()) {
Spacer(modifier = Modifier.height(16.dp))
ExtrasSection(notification.extrasJson)
}
}
}
@Composable
private fun ExtrasSection(extrasJson: String) {
var expanded by remember { mutableStateOf(false) }
TextButton(onClick = { expanded = !expanded }) {
Text(stringResource(R.string.detail_extras))
Icon(
if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null
)
}
val parsedExtras = remember(extrasJson) {
runCatching { Json.decodeFromString<JsonObject>(extrasJson) }.getOrNull()
}
AnimatedVisibility(visible = expanded) {
if (parsedExtras != null) {
Column(modifier = Modifier.padding(start = 8.dp)) {
parsedExtras.forEach { (key, value) ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(key, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
runCatching { value.jsonPrimitive.content }.getOrDefault(value.toString()),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
} else {
Text(extrasJson, style = MaterialTheme.typography.bodySmall)
}
}
}
private fun copyToClipboard(context: Context, notification: CapturedNotification) {
val text = buildString {
notification.title?.let { appendLine(it) }
notification.text?.let { appendLine(it) }
notification.bigText?.takeIf { it != notification.text }?.let { appendLine(it) }
}
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("notification", text))
Toast.makeText(context, R.string.detail_copied, Toast.LENGTH_SHORT).show()
}
private fun shareNotification(context: Context, notification: CapturedNotification) {
val text = buildString {
appendLine("[${notification.appName}]")
notification.title?.let { appendLine(it) }
notification.text?.let { appendLine(it) }
}
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(intent, null))
}
private fun openSourceApp(context: Context, packageName: String) {
val intent = context.packageManager.getLaunchIntentForPackage(packageName)
if (intent != null) context.startActivity(intent)
}

View file

@ -0,0 +1,49 @@
package com.roundingmobile.notisaver.presentation.detail
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.roundingmobile.notisaver.domain.model.CapturedNotification
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
import com.roundingmobile.notisaver.domain.usecase.GetNotificationDetailUseCase
import com.roundingmobile.notisaver.presentation.navigation.Route
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class DetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
getNotificationDetailUseCase: GetNotificationDetailUseCase,
private val repository: NotificationRepository
) : ViewModel() {
private val route = savedStateHandle.toRoute<Route.Detail>()
val uiState: StateFlow<DetailUiState> = getNotificationDetailUseCase(route.notificationId)
.map { notification ->
if (notification != null) DetailUiState.Success(notification)
else DetailUiState.NotFound
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), DetailUiState.Loading)
fun toggleBookmark() {
val state = uiState.value
if (state is DetailUiState.Success) {
viewModelScope.launch {
repository.setBookmarked(state.notification.id, !state.notification.isBookmarked)
}
}
}
}
sealed interface DetailUiState {
data object Loading : DetailUiState
data object NotFound : DetailUiState
data class Success(val notification: CapturedNotification) : DetailUiState
}

View file

@ -0,0 +1,110 @@
package com.roundingmobile.notisaver.presentation.export
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.roundingmobile.notisaver.R
import com.roundingmobile.notisaver.domain.model.ExportFormat
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
import com.roundingmobile.notisaver.instrumentation.testTrack
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExportScreen(
onBack: () -> Unit,
instrumentation: TestInstrumentation,
modifier: Modifier = Modifier,
viewModel: ExportViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.export_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
}
)
},
modifier = modifier
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Export notifications to Downloads folder", style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { viewModel.export(ExportFormat.CSV) },
modifier = Modifier
.fillMaxWidth()
.testTrack(instrumentation, "btn_export_csv", "CSV"),
enabled = state !is ExportState.Exporting
) { Text("Export as CSV") }
OutlinedButton(
onClick = { viewModel.export(ExportFormat.JSON) },
modifier = Modifier
.fillMaxWidth()
.testTrack(instrumentation, "btn_export_json", "JSON"),
enabled = state !is ExportState.Exporting
) { Text("Export as JSON") }
OutlinedButton(
onClick = { viewModel.export(ExportFormat.TXT) },
modifier = Modifier
.fillMaxWidth()
.testTrack(instrumentation, "btn_export_txt", "TXT"),
enabled = state !is ExportState.Exporting
) { Text("Export as TXT") }
Spacer(modifier = Modifier.height(16.dp))
when (val s = state) {
is ExportState.Exporting -> Text("Exporting…", color = MaterialTheme.colorScheme.primary)
is ExportState.Success -> {
Text(
"Saved to Downloads/${s.fileName}",
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.testTrack(instrumentation, "export_success")
)
}
is ExportState.Error -> {
Text(s.message, color = MaterialTheme.colorScheme.error)
}
else -> {}
}
}
}
}

View file

@ -0,0 +1,73 @@
package com.roundingmobile.notisaver.presentation.export
import android.content.ContentValues
import android.content.Context
import android.os.Environment
import android.provider.MediaStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.roundingmobile.notisaver.domain.model.ExportFormat
import com.roundingmobile.notisaver.domain.usecase.ExportNotificationsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
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 ExportViewModel @Inject constructor(
private val exportUseCase: ExportNotificationsUseCase,
@ApplicationContext private val context: Context
) : ViewModel() {
private val _state = MutableStateFlow<ExportState>(ExportState.Idle)
val state: StateFlow<ExportState> = _state.asStateFlow()
fun export(format: ExportFormat) {
_state.value = ExportState.Exporting
viewModelScope.launch {
try {
val content = exportUseCase(format)
val extension = when (format) {
ExportFormat.CSV -> "csv"
ExportFormat.JSON -> "json"
ExportFormat.TXT -> "txt"
ExportFormat.PDF -> "txt"
}
val mimeType = when (format) {
ExportFormat.CSV -> "text/csv"
ExportFormat.JSON -> "application/json"
ExportFormat.TXT, ExportFormat.PDF -> "text/plain"
}
val fileName = "notisaver_export_${System.currentTimeMillis()}.$extension"
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
put(MediaStore.Downloads.MIME_TYPE, mimeType)
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
if (uri != null) {
context.contentResolver.openOutputStream(uri)?.use { it.write(content.toByteArray()) }
_state.value = ExportState.Success(fileName)
} else {
_state.value = ExportState.Error("Failed to create file")
}
} catch (e: Exception) {
_state.value = ExportState.Error(e.message ?: "Export failed")
}
}
}
fun resetState() { _state.value = ExportState.Idle }
}
sealed interface ExportState {
data object Idle : ExportState
data object Exporting : ExportState
data class Success(val fileName: String) : ExportState
data class Error(val message: String) : ExportState
}

View file

@ -0,0 +1,206 @@
package com.roundingmobile.notisaver.presentation.lock
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Backspace
import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun LockScreen(
onPinEntered: (String) -> Boolean,
onBiometricClick: (() -> Unit)?,
isCooldown: Boolean,
cooldownSeconds: Int,
modifier: Modifier = Modifier
) {
var pin by remember { mutableStateOf("") }
var error by remember { mutableStateOf(false) }
val shakeOffset = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
// Auto-submit when 4 digits entered
LaunchedEffect(pin) {
if (pin.length == 4) {
val correct = onPinEntered(pin)
if (!correct) {
error = true
// Shake animation
scope.launch {
for (i in 0..5) {
shakeOffset.animateTo(if (i % 2 == 0) 20f else -20f, spring(stiffness = 2000f))
}
shakeOffset.animateTo(0f)
}
delay(200)
pin = ""
delay(1000)
error = false
}
}
}
Surface(
modifier = modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// App icon
Icon(
imageVector = Icons.Default.Fingerprint,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Unlock NotiSaver",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (isCooldown) "Too many attempts. Wait ${cooldownSeconds}s"
else if (error) "Incorrect PIN"
else "Enter your 4-digit PIN",
style = MaterialTheme.typography.bodyMedium,
color = if (error || isCooldown) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
// PIN dots
Row(
modifier = Modifier.offset { IntOffset(shakeOffset.value.toInt(), 0) },
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
repeat(4) { i ->
Box(
modifier = Modifier
.size(16.dp)
.clip(CircleShape)
.background(
if (i < pin.length) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outlineVariant
)
)
}
}
Spacer(modifier = Modifier.height(40.dp))
// Keypad
val keys = listOf(
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf("bio", "0", "del")
)
keys.forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
row.forEach { key ->
when (key) {
"bio" -> {
if (onBiometricClick != null) {
IconButton(
onClick = onBiometricClick,
modifier = Modifier.size(72.dp),
enabled = !isCooldown
) {
Icon(
Icons.Default.Fingerprint,
contentDescription = "Use biometrics",
modifier = Modifier.size(32.dp)
)
}
} else {
Spacer(modifier = Modifier.size(72.dp))
}
}
"del" -> {
IconButton(
onClick = { if (pin.isNotEmpty()) pin = pin.dropLast(1) },
modifier = Modifier.size(72.dp),
enabled = !isCooldown
) {
Icon(
Icons.Default.Backspace,
contentDescription = "Delete",
modifier = Modifier.size(28.dp)
)
}
}
else -> {
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.clickable(enabled = pin.length < 4 && !isCooldown) {
pin += key
},
contentAlignment = Alignment.Center
) {
Text(
text = key,
fontSize = 24.sp,
textAlign = TextAlign.Center
)
}
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}

View file

@ -0,0 +1,84 @@
package com.roundingmobile.notisaver.presentation.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
import com.roundingmobile.notisaver.presentation.detail.DetailScreen
import com.roundingmobile.notisaver.presentation.export.ExportScreen
import com.roundingmobile.notisaver.presentation.settings.FilterRulesScreen
import com.roundingmobile.notisaver.presentation.settings.SettingsScreen
import com.roundingmobile.notisaver.presentation.stats.StatsScreen
import com.roundingmobile.notisaver.presentation.timeline.TimelineScreen
@Composable
fun AppNavGraph(
navController: NavHostController,
instrumentation: TestInstrumentation,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = Route.Timeline,
modifier = modifier
) {
composable<Route.Timeline> {
TimelineScreen(
onNotificationClick = { id -> navController.navigate(Route.Detail(id)) },
instrumentation = instrumentation
)
}
composable<Route.Stats> {
StatsScreen(instrumentation = instrumentation)
}
composable<Route.Settings> {
SettingsScreen(
instrumentation = instrumentation,
onNavigateToFilterRules = { navController.navigate(Route.FilterRules) }
)
}
composable<Route.FilterRules> {
FilterRulesScreen(
onBack = { navController.popBackStack() },
instrumentation = instrumentation
)
}
composable<Route.Detail> {
DetailScreen(
onBack = { navController.popBackStack() },
instrumentation = instrumentation
)
}
composable<Route.Export> {
ExportScreen(
onBack = { navController.popBackStack() },
instrumentation = instrumentation
)
}
composable<Route.Conversation> { backStackEntry ->
val conv = backStackEntry.toRoute<Route.Conversation>()
PlaceholderScreen("Conversation: ${conv.sender}")
}
}
}
@Composable
private fun PlaceholderScreen(name: String) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(text = name)
}
}

View file

@ -0,0 +1,13 @@
package com.roundingmobile.notisaver.presentation.navigation
import kotlinx.serialization.Serializable
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 object Export : Route
@Serializable data object FilterRules : Route
@Serializable data class Conversation(val packageName: String, val sender: String) : Route
}

View file

@ -0,0 +1,180 @@
package com.roundingmobile.notisaver.presentation.onboarding
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.text.TextUtils
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.platform.LocalLifecycleOwner
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.roundingmobile.notisaver.service.NotificationCaptureService
@Composable
fun OnboardingScreen(
onComplete: () -> Unit,
modifier: Modifier = Modifier
) {
var step by rememberSaveable { mutableIntStateOf(0) }
val context = LocalContext.current
var permissionGranted by remember { mutableStateOf(isNotificationListenerEnabled(context)) }
// Re-check permission when returning from settings
val lifecycleOwner = LocalLifecycleOwner.current
androidx.compose.runtime.DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
permissionGranted = isNotificationListenerEnabled(context)
if (permissionGranted && step == 1) step = 2
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Scaffold(modifier = modifier) { innerPadding ->
AnimatedContent(targetState = step, label = "onboarding") { currentStep ->
when (currentStep) {
0 -> OnboardingPage(
icon = Icons.Default.Notifications,
title = "Welcome to NotiSaver",
description = "Capture and save all your notifications. Never miss a deleted message again.",
buttonText = "Get Started",
onAction = { step = 1 },
modifier = Modifier.padding(innerPadding)
)
1 -> OnboardingPage(
icon = Icons.Default.Security,
title = "Grant Permission",
description = if (permissionGranted)
"Permission granted! Tap Continue."
else
"NotiSaver needs notification access to capture your notifications. Tap below to open system settings and enable NotiSaver.",
buttonText = if (permissionGranted) "Continue" else "Open Settings",
onAction = {
if (permissionGranted) {
step = 2
} else {
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
}
},
secondaryButtonText = if (!permissionGranted) "Skip for now" else null,
onSecondaryAction = if (!permissionGranted) { { step = 2 } } else null,
modifier = Modifier.padding(innerPadding)
)
2 -> OnboardingPage(
icon = Icons.Default.CheckCircle,
title = if (permissionGranted) "You're All Set!" else "Almost Done",
description = if (permissionGranted)
"Notifications will be captured automatically. Browse your timeline, search, and filter anytime."
else
"You can grant notification access later in Settings. Without it, NotiSaver can't capture notifications.",
buttonText = "Open NotiSaver",
onAction = onComplete,
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
fun isNotificationListenerEnabled(context: Context): Boolean {
val pkgName = context.packageName
val flat = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners")
if (!TextUtils.isEmpty(flat)) {
val names = flat.split(":")
for (name in names) {
val cn = ComponentName.unflattenFromString(name)
if (cn != null && cn.packageName == pkgName) return true
}
}
return false
}
@Composable
private fun OnboardingPage(
icon: ImageVector,
title: String,
description: String,
buttonText: String,
onAction: () -> Unit,
secondaryButtonText: String? = null,
onSecondaryAction: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(96.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(48.dp))
Button(
onClick = onAction,
modifier = Modifier.fillMaxWidth()
) {
Text(buttonText)
}
if (secondaryButtonText != null && onSecondaryAction != null) {
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = onSecondaryAction,
modifier = Modifier.fillMaxWidth()
) {
Text(secondaryButtonText)
}
}
}
}

View file

@ -0,0 +1,87 @@
package com.roundingmobile.notisaver.presentation.onboarding
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun PermissionScreen(modifier: Modifier = Modifier) {
val context = LocalContext.current
Surface(
modifier = modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = null,
modifier = Modifier.size(96.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = "One more step to get started",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "NotiSaver needs notification access to capture and save your notifications. Your data stays 100% on this device — nothing is sent anywhere.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(48.dp))
Button(
onClick = {
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
},
modifier = Modifier.fillMaxWidth()
) {
Text("Grant notification access")
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "You can revoke this anytime in Settings",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}

View file

@ -0,0 +1,212 @@
package com.roundingmobile.notisaver.presentation.settings
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
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.unit.dp
import com.roundingmobile.notisaver.domain.model.FilterAction
import com.roundingmobile.notisaver.domain.model.FilterRule
import com.roundingmobile.notisaver.domain.model.MatchField
import com.roundingmobile.notisaver.domain.model.MatchType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterRuleEditorSheet(
initialRule: FilterRule,
onSave: (FilterRule) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var name by remember { mutableStateOf(initialRule.name) }
var pattern by remember { mutableStateOf(initialRule.pattern) }
var matchField by remember { mutableStateOf(initialRule.matchField) }
var matchType by remember { mutableStateOf(initialRule.matchType) }
var action by remember { mutableStateOf(initialRule.action) }
var scopeAllApps by remember { mutableStateOf(initialRule.packageName == null) }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
if (initialRule.id == 0L) "Create filter rule" else "Edit filter rule",
style = MaterialTheme.typography.titleLarge
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Rule name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = pattern,
onValueChange = { pattern = it },
label = { Text("Match pattern") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Match field
Text("Match in", style = MaterialTheme.typography.labelLarge)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
val fields = listOf(
MatchField.TITLE to "Title",
MatchField.TEXT to "Text",
MatchField.TITLE_OR_TEXT to "Both"
)
fields.forEach { (field, label) ->
FilterChip(
selected = matchField == field,
onClick = { matchField = field },
label = { Text(label, style = MaterialTheme.typography.labelSmall) }
)
}
}
// Match type
Text("Match mode", style = MaterialTheme.typography.labelLarge)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
val types = listOf(
MatchType.CONTAINS to "Contains",
MatchType.EXACT to "Exact",
MatchType.REGEX to "Regex"
)
types.forEachIndexed { index, (type, label) ->
SegmentedButton(
selected = matchType == type,
onClick = { matchType = type },
shape = SegmentedButtonDefaults.itemShape(index, types.size)
) { Text(label, style = MaterialTheme.typography.labelSmall) }
}
}
// Action
Text("Action", style = MaterialTheme.typography.labelLarge)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
val actions = listOf(
FilterAction.SUPPRESS to "Don't save",
FilterAction.LOW_PRIORITY to "Low priority"
)
actions.forEachIndexed { index, (a, label) ->
SegmentedButton(
selected = action == a,
onClick = { action = a },
shape = SegmentedButtonDefaults.itemShape(index, actions.size)
) { Text(label, style = MaterialTheme.typography.labelSmall) }
}
}
// Scope
if (initialRule.packageName != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text("Apply to all apps", style = MaterialTheme.typography.bodyMedium)
Text(
if (scopeAllApps) "Matches from any app"
else "Only ${initialRule.appName ?: initialRule.packageName}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(checked = scopeAllApps, onCheckedChange = { scopeAllApps = it })
}
}
// Preview
if (pattern.isNotBlank()) {
val fieldLabel = when (matchField) {
MatchField.TITLE -> "title"
MatchField.TEXT -> "text"
MatchField.TITLE_OR_TEXT -> "title or text"
MatchField.APP_NAME -> "app name"
MatchField.PACKAGE_NAME -> "package"
}
val typeLabel = when (matchType) {
MatchType.CONTAINS -> "contains"
MatchType.EXACT -> "equals"
MatchType.REGEX -> "matches"
}
val actionLabel = when (action) {
FilterAction.SUPPRESS -> "won't be saved"
FilterAction.LOW_PRIORITY -> "will be marked low priority"
}
Text(
"Notifications where $fieldLabel $typeLabel \"$pattern\" $actionLabel",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp)
)
}
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) { Text("Cancel") }
Button(
onClick = {
onSave(
initialRule.copy(
name = name.ifBlank { "Filter rule" },
pattern = pattern,
matchField = matchField,
matchType = matchType,
action = action,
packageName = if (scopeAllApps) null else initialRule.packageName,
appName = if (scopeAllApps) null else initialRule.appName,
createdAt = System.currentTimeMillis()
)
)
},
enabled = pattern.isNotBlank()
) { Text("Save") }
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}

View file

@ -0,0 +1,304 @@
package com.roundingmobile.notisaver.presentation.settings
import androidx.compose.animation.animateColorAsState
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
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.clip
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.notisaver.domain.model.FilterAction
import com.roundingmobile.notisaver.domain.model.FilterRule
import com.roundingmobile.notisaver.domain.model.MatchField
import com.roundingmobile.notisaver.domain.model.MatchType
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
import com.roundingmobile.notisaver.presentation.common.AppIcon
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterRulesScreen(
onBack: () -> Unit,
instrumentation: TestInstrumentation,
modifier: Modifier = Modifier,
viewModel: FilterRulesViewModel = hiltViewModel()
) {
val rules by viewModel.rules.collectAsStateWithLifecycle()
var showEditor by remember { mutableStateOf(false) }
var editingRule by remember { mutableStateOf<FilterRule?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Filter Rules") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { showEditor = true }) {
Icon(Icons.Default.Add, contentDescription = "Add filter rule")
}
},
modifier = modifier
) { innerPadding ->
if (rules.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.FilterAlt,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"No filter rules",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp)
)
Text(
"Tap + to create one, or long-press a notification",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = 8.dp, end = 8.dp, top = 4.dp, bottom = 80.dp
)
) {
items(rules, key = { it.id }) { rule ->
FilterRuleItem(
rule = rule,
onToggle = { enabled -> viewModel.toggleRule(rule.id, enabled) },
onDelete = { viewModel.deleteRule(rule.id) },
onClick = { editingRule = rule },
modifier = Modifier.animateItem()
)
}
}
}
if (showEditor) {
FilterRuleEditorSheet(
initialRule = FilterRule(
name = "",
action = FilterAction.SUPPRESS,
matchField = MatchField.TITLE_OR_TEXT,
matchType = MatchType.CONTAINS,
pattern = "",
createdAt = System.currentTimeMillis()
),
onSave = { rule ->
viewModel.createRule(rule)
showEditor = false
},
onDismiss = { showEditor = false }
)
}
editingRule?.let { rule ->
FilterRuleEditorSheet(
initialRule = rule,
onSave = { updated ->
viewModel.updateRule(updated)
editingRule = null
},
onDismiss = { editingRule = null }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FilterRuleItem(
rule: FilterRule,
onToggle: (Boolean) -> Unit,
onDelete: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
if (rule.isBuiltIn) {
FilterRuleCard(rule = rule, onToggle = onToggle, onClick = onClick, modifier = modifier)
} else {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
onDelete()
true
} else false
}
)
SwipeToDismissBox(
state = dismissState,
backgroundContent = {
Box(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 2.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.CenterEnd
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(horizontal = 20.dp)
)
}
},
enableDismissFromStartToEnd = false,
enableDismissFromEndToStart = true,
modifier = modifier
) {
FilterRuleCard(rule = rule, onToggle = onToggle, onClick = onClick)
}
}
}
@Composable
private fun FilterRuleCard(
rule: FilterRule,
onToggle: (Boolean) -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
onClick = onClick
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (rule.packageName != null) {
AppIcon(
packageName = rule.packageName,
appName = rule.appName ?: rule.packageName,
size = 36.dp
)
} else {
Icon(
Icons.Default.FilterAlt,
contentDescription = null,
modifier = Modifier.size(36.dp),
tint = MaterialTheme.colorScheme.primary
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
rule.name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
if (rule.isBuiltIn) {
Text(
"Preset",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
Text(
buildRuleSummary(rule),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
Switch(
checked = rule.isEnabled,
onCheckedChange = onToggle
)
}
}
}
private fun buildRuleSummary(rule: FilterRule): String {
val actionLabel = when (rule.action) {
FilterAction.SUPPRESS -> "Don't save"
FilterAction.LOW_PRIORITY -> "Low priority"
}
val fieldLabel = when (rule.matchField) {
MatchField.TITLE -> "title"
MatchField.TEXT -> "text"
MatchField.TITLE_OR_TEXT -> "title/text"
MatchField.APP_NAME -> "app name"
MatchField.PACKAGE_NAME -> "package"
}
val typeLabel = when (rule.matchType) {
MatchType.CONTAINS -> "contains"
MatchType.EXACT -> "equals"
MatchType.REGEX -> "matches"
}
val scope = if (rule.appName != null) " from ${rule.appName}" else ""
return "$actionLabel when $fieldLabel $typeLabel \"${rule.pattern}\"$scope"
}

View file

@ -0,0 +1,94 @@
package com.roundingmobile.notisaver.presentation.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.roundingmobile.notisaver.domain.model.CapturedNotification
import com.roundingmobile.notisaver.domain.model.FilterAction
import com.roundingmobile.notisaver.domain.model.FilterRule
import com.roundingmobile.notisaver.domain.model.MatchField
import com.roundingmobile.notisaver.domain.model.MatchType
import com.roundingmobile.notisaver.domain.repository.FilterRuleRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class FilterRulesViewModel @Inject constructor(
private val repository: FilterRuleRepository
) : ViewModel() {
val rules: StateFlow<List<FilterRule>> = repository.getAllRulesFlow()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
init {
viewModelScope.launch { repository.seedPresetsIfNeeded() }
}
fun createRule(rule: FilterRule) {
viewModelScope.launch { repository.insert(rule) }
}
fun updateRule(rule: FilterRule) {
viewModelScope.launch { repository.update(rule) }
}
fun deleteRule(id: Long) {
viewModelScope.launch { repository.deleteById(id) }
}
fun toggleRule(id: Long, enabled: Boolean) {
viewModelScope.launch { repository.setEnabled(id, enabled) }
}
companion object {
private val SUMMARY_PATTERN = Regex("\\d+\\s+(new\\s+)?(messages?|notifications?|emails?|chats?|updates?)")
private val NUMBER_HEAVY = Regex("\\d{2,}")
fun prefillFromNotification(notification: CapturedNotification): FilterRule {
val title = notification.title
val text = notification.text
val appName = notification.appName
// Determine best pattern and field
val (pattern, field) = when {
// Summary notification: "3 messages from 2 chats" → match field=TEXT
text != null && SUMMARY_PATTERN.containsMatchIn(text) -> {
val generalized = SUMMARY_PATTERN.replace(text) { match ->
match.value.replace(Regex("^\\d+"), "\\\\d+")
}
generalized to MatchField.TEXT
}
// Title is same as app name → it's generic, use text
title != null && title.equals(appName, ignoreCase = true) -> {
(text ?: "") to MatchField.TEXT
}
// Title looks like a person/sender name → offer title filter
title != null && title.isNotBlank() -> {
title to MatchField.TITLE
}
// Fallback to text
else -> {
(text ?: "") to MatchField.TITLE_OR_TEXT
}
}
// Choose match type
val matchType = if (pattern.contains("\\d+") || pattern.contains(".*")) MatchType.REGEX
else MatchType.CONTAINS
return FilterRule(
name = "Filter ${title ?: appName}",
action = FilterAction.SUPPRESS,
matchField = field,
matchType = matchType,
pattern = pattern,
packageName = notification.packageName,
appName = appName,
createdAt = System.currentTimeMillis()
)
}
}
}

View file

@ -0,0 +1,118 @@
package com.roundingmobile.notisaver.presentation.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun PinSetupDialog(
title: String = "Set PIN",
onConfirm: (String) -> Unit,
onDismiss: () -> Unit
) {
var pin1 by remember { mutableStateOf("") }
var pin2 by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Enter a 4-digit PIN", style = MaterialTheme.typography.bodyMedium)
OutlinedTextField(
value = pin1,
onValueChange = { if (it.length <= 4 && it.all(Char::isDigit)) pin1 = it },
label = { Text("PIN") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = pin2,
onValueChange = { if (it.length <= 4 && it.all(Char::isDigit)) pin2 = it },
label = { Text("Confirm PIN") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (error != null) {
Text(error!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
},
confirmButton = {
TextButton(onClick = {
when {
pin1.length != 4 -> error = "PIN must be 4 digits"
pin1 != pin2 -> error = "PINs don't match"
else -> onConfirm(pin1)
}
}) { Text("Set PIN") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@Composable
fun PinVerifyDialog(
title: String = "Enter current PIN",
onVerify: (String) -> Boolean,
onSuccess: () -> Unit,
onDismiss: () -> Unit
) {
var pin by remember { mutableStateOf("") }
var error by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
OutlinedTextField(
value = pin,
onValueChange = { if (it.length <= 4 && it.all(Char::isDigit)) { pin = it; error = false } },
label = { Text("PIN") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (error) {
Spacer(modifier = Modifier.height(4.dp))
Text("Incorrect PIN", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
},
confirmButton = {
TextButton(onClick = {
if (onVerify(pin)) onSuccess()
else { error = true; pin = "" }
}) { Text("Verify") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}

View file

@ -0,0 +1,541 @@
package com.roundingmobile.notisaver.presentation.settings
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.RadioButton
import androidx.compose.foundation.rememberScrollState
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.DeleteSweep
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Schedule
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.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
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.notisaver.R
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
import com.roundingmobile.notisaver.instrumentation.testTrack
import com.roundingmobile.notisaver.presentation.common.AppIcon
import com.roundingmobile.notisaver.presentation.theme.AppThemeType
import com.roundingmobile.notisaver.presentation.theme.AppThemes
import com.roundingmobile.notisaver.util.AppDateFormatter
import com.roundingmobile.notisaver.util.DateFormatOption
import com.roundingmobile.notisaver.util.TimeFormatOption
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
instrumentation: TestInstrumentation,
onNavigateToFilterRules: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val hiddenApps by viewModel.hiddenAppsState.collectAsStateWithLifecycle()
val clearDone by viewModel.clearDone.collectAsStateWithLifecycle()
val activeRuleCount by viewModel.activeRuleCount.collectAsStateWithLifecycle()
var showRetentionDialog by remember { mutableStateOf(false) }
var showClearDialog by remember { mutableStateOf(false) }
var showHiddenAppsSheet by remember { mutableStateOf(false) }
var showCollapseDialog by remember { mutableStateOf(false) }
// Navigate after clear
LaunchedEffect(clearDone) {
if (clearDone) viewModel.onClearDoneHandled()
}
Scaffold(
topBar = {
TopAppBar(expandedHeight = 48.dp, title = { Text(stringResource(R.string.settings_title)) })
},
modifier = modifier
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(bottom = 80.dp)
.verticalScroll(rememberScrollState())
) {
// --- Appearance ---
SectionHeader("Appearance")
Text(
text = "Theme",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
LazyRow(
contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(AppThemeType.entries) { type ->
ThemePreviewCard(
type = type,
isSelected = state.themeType == type,
onClick = { viewModel.setTheme(type) },
modifier = Modifier.testTrack(instrumentation, "theme_${type.name}")
)
}
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// --- Privacy ---
SectionHeader("Privacy")
SettingsRow(
icon = Icons.Default.Lock,
title = stringResource(R.string.settings_app_lock),
subtitle = if (!state.deviceSecure) "Set up device lock screen first"
else if (state.lockEnabled) "Uses device PIN / fingerprint / face"
else "Disabled",
modifier = Modifier.testTrack(instrumentation, "settings_lock", "${state.lockEnabled}")
) {
Switch(
checked = state.lockEnabled,
onCheckedChange = { enabled -> viewModel.setLockEnabled(enabled) },
enabled = state.deviceSecure
)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
val hiddenCount = hiddenApps.count { it.isHidden }
SettingsRow(
icon = Icons.Default.VisibilityOff,
title = stringResource(R.string.settings_hidden_apps),
subtitle = if (hiddenCount > 0) "$hiddenCount app${if (hiddenCount > 1) "s" else ""} hidden" else "None hidden",
onClick = { showHiddenAppsSheet = true },
modifier = Modifier.testTrack(instrumentation, "settings_hidden_apps")
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// --- Filtering ---
SectionHeader("Notification Filtering")
SettingsRow(
icon = Icons.Default.VisibilityOff,
title = "Filter system notifications",
subtitle = "Hide charging, USB, VPN, and other ongoing system notifications"
) {
Switch(checked = state.filterSystem, onCheckedChange = { viewModel.setFilterSystem(it) })
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
SettingsRow(
icon = Icons.Default.Schedule,
title = "Collapse rapid updates",
subtitle = collapseWindowLabel(state.collapseWindowMs),
onClick = { showCollapseDialog = true }
) {
Switch(checked = state.collapseRapid, onCheckedChange = { viewModel.setCollapseRapid(it) })
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
SettingsRow(
icon = Icons.Default.FilterAlt,
title = "Filter rules",
subtitle = if (activeRuleCount > 0) "$activeRuleCount active rule${if (activeRuleCount != 1) "s" else ""}"
else "No active rules",
onClick = onNavigateToFilterRules
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// --- Date & Time Format ---
Text(
text = "Date format",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
DateFormatSelector(
selected = state.dateFormat,
onSelect = { viewModel.setDateFormat(it) }
)
Text(
text = "Time format",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
TimeFormatSelector(
selected = state.timeFormat,
onSelect = { viewModel.setTimeFormat(it) }
)
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// --- Storage ---
SectionHeader("Storage")
SettingsRow(
icon = Icons.Default.Schedule,
title = stringResource(R.string.settings_retention),
subtitle = if (state.retentionDays == 0) "Unlimited" else "${state.retentionDays} days",
onClick = { showRetentionDialog = true },
modifier = Modifier.testTrack(instrumentation, "settings_retention", "${state.retentionDays}")
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
SettingsRow(
icon = Icons.Default.DeleteSweep,
title = "Clear all data",
subtitle = "Delete all captured notifications",
onClick = { showClearDialog = true },
modifier = Modifier.testTrack(instrumentation, "settings_clear_data")
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// --- About ---
SectionHeader("About")
SettingsRow(
icon = Icons.Default.Info,
title = stringResource(R.string.settings_about),
subtitle = "NotiSaver v1.0.0",
modifier = Modifier.testTrack(instrumentation, "settings_about")
)
Spacer(modifier = Modifier.height(32.dp))
}
}
// Collapse window dialog
if (showCollapseDialog) {
CollapseWindowDialog(
currentMs = state.collapseWindowMs,
onSelect = { viewModel.setCollapseWindow(it); showCollapseDialog = false },
onDismiss = { showCollapseDialog = false }
)
}
// Retention dialog
if (showRetentionDialog) {
RetentionDialog(
currentDays = state.retentionDays,
onSelect = { viewModel.setRetentionDays(it); showRetentionDialog = false },
onDismiss = { showRetentionDialog = false }
)
}
// Clear data confirmation dialog
if (showClearDialog) {
AlertDialog(
onDismissRequest = { showClearDialog = false },
title = { Text("Clear all data?") },
text = { Text("This will delete all saved notifications. This cannot be undone.") },
confirmButton = {
TextButton(onClick = {
viewModel.clearAllData()
showClearDialog = false
}) {
Text("Delete all", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showClearDialog = false }) { Text("Cancel") }
}
)
}
// Hidden apps bottom sheet
if (showHiddenAppsSheet) {
HiddenAppsSheet(
apps = hiddenApps,
onToggle = { pkg, hidden -> viewModel.toggleHiddenApp(pkg, hidden) },
onDismiss = { showHiddenAppsSheet = false }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HiddenAppsSheet(
apps: List<HiddenAppItem>,
onToggle: (String, Boolean) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(modifier = Modifier.padding(bottom = 32.dp)) {
Text(
"Hidden Apps",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
Text(
"Hidden apps will not be captured or shown in the timeline.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 0.dp)
)
Spacer(modifier = Modifier.height(12.dp))
if (apps.isEmpty()) {
Text(
"No apps have sent notifications yet.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp)
)
} else {
apps.forEach { app ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onToggle(app.packageName, app.isHidden) }
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppIcon(
packageName = app.packageName,
appName = app.appName,
size = 36.dp
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(app.appName, style = MaterialTheme.typography.bodyLarge)
Text(
"${app.count} notification${if (app.count != 1) "s" else ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = app.isHidden,
onCheckedChange = { onToggle(app.packageName, app.isHidden) }
)
}
}
}
}
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp)
)
}
@Composable
private fun SettingsRow(
icon: ImageVector,
title: String,
subtitle: String? = null,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
trailing: @Composable (() -> Unit)? = null
) {
Row(
modifier = modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge)
if (subtitle != null) {
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (trailing != null) trailing()
}
}
@Composable
private fun ThemePreviewCard(
type: AppThemeType,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colors = if (type == AppThemeType.SYSTEM &&
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
) {
androidx.compose.material3.dynamicLightColorScheme(
androidx.compose.ui.platform.LocalContext.current
)
} else {
AppThemes.get(type).light
}
Card(
modifier = modifier.width(90.dp).clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
elevation = CardDefaults.cardElevation(if (isSelected) 4.dp else 1.dp)
) {
Column(
modifier = Modifier.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Box(Modifier.size(16.dp).clip(CircleShape).background(colors.primary))
Box(Modifier.size(16.dp).clip(CircleShape).background(colors.secondary))
Box(Modifier.size(16.dp).clip(CircleShape).background(colors.tertiary))
}
Box(Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp)).background(colors.primary))
Box(Modifier.fillMaxWidth(0.7f).height(4.dp).clip(RoundedCornerShape(2.dp)).background(colors.secondary))
Text(type.displayName, style = MaterialTheme.typography.labelSmall, maxLines = 1)
}
}
}
private fun collapseWindowLabel(ms: Long): String = when (ms) {
30_000L -> "Within 30 seconds"
60_000L -> "Within 1 minute"
300_000L -> "Within 5 minutes"
else -> "Within ${ms / 1000}s"
}
@Composable
private fun CollapseWindowDialog(currentMs: Long, onSelect: (Long) -> Unit, onDismiss: () -> Unit) {
val options = listOf(30_000L, 60_000L, 300_000L)
val labels = listOf("30 seconds", "1 minute", "5 minutes")
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Collapse window") },
text = {
Column {
options.forEachIndexed { index, ms ->
Row(
modifier = Modifier.fillMaxWidth().clickable { onSelect(ms) }.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = currentMs == ms, onClick = { onSelect(ms) })
Text(labels[index], modifier = Modifier.padding(start = 8.dp))
}
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
)
}
@Composable
private fun RetentionDialog(currentDays: Int, onSelect: (Int) -> Unit, onDismiss: () -> Unit) {
val options = listOf(1, 7, 14, 30, 90, 0)
val labels = listOf("1 day", "7 days", "14 days", "30 days", "90 days", "Unlimited")
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_retention)) },
text = {
Column {
options.forEachIndexed { index, days ->
Row(
modifier = Modifier.fillMaxWidth().clickable { onSelect(days) }.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = currentDays == days, onClick = { onSelect(days) })
Text(labels[index], modifier = Modifier.padding(start = 8.dp))
}
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
)
}
@Composable
private fun DateFormatSelector(selected: DateFormatOption, onSelect: (DateFormatOption) -> Unit) {
val now = System.currentTimeMillis()
LazyRow(contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(DateFormatOption.entries.toList()) { option ->
val isSelected = selected == option
val preview = if (option.pattern != null)
java.text.SimpleDateFormat(option.pattern, java.util.Locale.getDefault()).format(java.util.Date(now))
else java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).format(java.util.Date(now))
Card(
modifier = Modifier.width(100.dp).clickable { onSelect(option) },
shape = RoundedCornerShape(12.dp),
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
elevation = CardDefaults.cardElevation(if (isSelected) 4.dp else 1.dp)
) {
Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(option.label, style = MaterialTheme.typography.labelSmall, maxLines = 1)
Text(preview, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, maxLines = 1)
}
}
}
}
}
@Composable
private fun TimeFormatSelector(selected: TimeFormatOption, onSelect: (TimeFormatOption) -> Unit) {
val now = System.currentTimeMillis()
LazyRow(contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(TimeFormatOption.entries.toList()) { option ->
val isSelected = selected == option
val preview = if (option.pattern != null)
java.text.SimpleDateFormat(option.pattern, java.util.Locale.getDefault()).format(java.util.Date(now))
else java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date(now))
Card(
modifier = Modifier.width(100.dp).clickable { onSelect(option) },
shape = RoundedCornerShape(12.dp),
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
elevation = CardDefaults.cardElevation(if (isSelected) 4.dp else 1.dp)
) {
Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(option.label, style = MaterialTheme.typography.labelSmall, maxLines = 1)
Text(preview, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, maxLines = 1)
}
}
}
}
}

View file

@ -0,0 +1,157 @@
package com.roundingmobile.notisaver.presentation.settings
import android.content.SharedPreferences
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.roundingmobile.notisaver.data.local.db.dao.HiddenAppDao
import com.roundingmobile.notisaver.data.local.db.entity.HiddenAppEntity
import com.roundingmobile.notisaver.domain.repository.FilterRuleRepository
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
import com.roundingmobile.notisaver.presentation.theme.AppThemeType
import com.roundingmobile.notisaver.security.AppLockManager
import com.roundingmobile.notisaver.security.NotificationFilter
import com.roundingmobile.notisaver.util.AppDateFormatter
import com.roundingmobile.notisaver.util.DateFormatOption
import com.roundingmobile.notisaver.util.TimeFormatOption
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.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val prefs: SharedPreferences,
private val repository: NotificationRepository,
private val hiddenAppDao: HiddenAppDao,
private val filterRuleRepository: FilterRuleRepository,
val lockManager: AppLockManager,
val notificationFilter: NotificationFilter,
val dateFormatter: AppDateFormatter
) : ViewModel() {
private val _state = MutableStateFlow(loadState())
val state: StateFlow<SettingsState> = _state.asStateFlow()
private val _clearDone = MutableStateFlow(false)
val clearDone: StateFlow<Boolean> = _clearDone.asStateFlow()
val activeRuleCount: StateFlow<Int> = filterRuleRepository.getEnabledCountFlow()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
val hiddenAppsState: StateFlow<List<HiddenAppItem>> =
combine(
repository.getDistinctApps(),
hiddenAppDao.getAllFlow()
) { apps, hiddenEntities ->
val hiddenSet = hiddenEntities.map { it.packageName }.toSet()
apps.map { app ->
HiddenAppItem(
packageName = app.packageName,
appName = app.appName,
count = app.notificationCount,
isHidden = app.packageName in hiddenSet
)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private fun loadState(): SettingsState {
val themeName = prefs.getString(KEY_THEME, AppThemeType.SYSTEM.name) ?: AppThemeType.SYSTEM.name
return SettingsState(
retentionDays = prefs.getInt(KEY_RETENTION_DAYS, 30),
lockEnabled = lockManager.isLockEnabled,
deviceSecure = lockManager.isDeviceSecure,
themeType = runCatching { AppThemeType.valueOf(themeName) }.getOrDefault(AppThemeType.SYSTEM),
filterSystem = notificationFilter.filterSystemEnabled,
collapseRapid = notificationFilter.collapseRapidEnabled,
collapseWindowMs = notificationFilter.collapseWindowMs,
dateFormat = dateFormatter.dateFormat,
timeFormat = dateFormatter.timeFormat
)
}
fun setFilterSystem(enabled: Boolean) {
notificationFilter.setFilterSystem(enabled)
_state.value = _state.value.copy(filterSystem = enabled)
}
fun setCollapseRapid(enabled: Boolean) {
notificationFilter.setCollapseRapid(enabled)
_state.value = _state.value.copy(collapseRapid = enabled)
}
fun setCollapseWindow(ms: Long) {
notificationFilter.setCollapseWindow(ms)
_state.value = _state.value.copy(collapseWindowMs = ms)
}
fun setLockEnabled(enabled: Boolean) {
if (enabled) lockManager.enableLock() else lockManager.disableLock()
_state.value = _state.value.copy(lockEnabled = enabled)
}
fun setRetentionDays(days: Int) {
prefs.edit().putInt(KEY_RETENTION_DAYS, days).apply()
_state.value = _state.value.copy(retentionDays = days)
}
fun setDateFormat(option: DateFormatOption) {
dateFormatter.setDateFormat(option)
_state.value = _state.value.copy(dateFormat = option)
}
fun setTimeFormat(option: TimeFormatOption) {
dateFormatter.setTimeFormat(option)
_state.value = _state.value.copy(timeFormat = option)
}
fun setTheme(type: AppThemeType) {
prefs.edit().putString(KEY_THEME, type.name).apply()
_state.value = _state.value.copy(themeType = type)
}
fun toggleHiddenApp(packageName: String, currentlyHidden: Boolean) {
viewModelScope.launch {
if (currentlyHidden) hiddenAppDao.delete(packageName)
else hiddenAppDao.insert(HiddenAppEntity(packageName, System.currentTimeMillis()))
}
}
fun clearAllData() {
viewModelScope.launch {
repository.deleteAll()
_clearDone.value = true
}
}
fun onClearDoneHandled() { _clearDone.value = false }
companion object {
const val KEY_RETENTION_DAYS = "retention_days"
const val KEY_THEME = "app_theme"
const val KEY_GROUP_BY_APP = "group_by_app"
}
}
data class SettingsState(
val retentionDays: Int = 30,
val lockEnabled: Boolean = false,
val deviceSecure: Boolean = true,
val themeType: AppThemeType = AppThemeType.SYSTEM,
val filterSystem: Boolean = true,
val collapseRapid: Boolean = true,
val collapseWindowMs: Long = 60_000L,
val dateFormat: DateFormatOption = DateFormatOption.SYSTEM,
val timeFormat: TimeFormatOption = TimeFormatOption.SYSTEM
)
data class HiddenAppItem(
val packageName: String,
val appName: String,
val count: Int,
val isHidden: Boolean
)

View file

@ -0,0 +1,122 @@
package com.roundingmobile.notisaver.presentation.stats
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.roundingmobile.notisaver.R
import com.roundingmobile.notisaver.domain.model.AppInfo
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
import com.roundingmobile.notisaver.instrumentation.testTrack
import com.roundingmobile.notisaver.presentation.common.EmptyState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StatsScreen(
instrumentation: TestInstrumentation,
modifier: Modifier = Modifier,
viewModel: StatsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = { TopAppBar(title = { Text(stringResource(R.string.nav_stats)) }) },
modifier = modifier
) { innerPadding ->
when (val state = uiState) {
is StatsUiState.Loading -> {}
is StatsUiState.Empty -> {
EmptyState(
message = "No data yet",
modifier = Modifier.padding(innerPadding)
)
}
is StatsUiState.Success -> {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp)
.testTrack(instrumentation, "stats_content", "total=${state.totalCount}")
) {
item {
Spacer(modifier = Modifier.height(16.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Total Notifications", style = MaterialTheme.typography.titleMedium)
Text(
"${state.totalCount}",
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Text("Top Apps", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
}
itemsIndexed(state.topApps) { index, app ->
AppStatRow(
app = app,
maxCount = state.topApps.first().notificationCount,
modifier = Modifier.testTrack(
instrumentation,
"stat_app_$index",
"${app.packageName}=${app.notificationCount}"
)
)
}
}
}
}
}
}
@Composable
private fun AppStatRow(
app: AppInfo,
maxCount: Int,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(app.appName, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Text("${app.notificationCount}", style = MaterialTheme.typography.titleMedium)
}
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { if (maxCount > 0) app.notificationCount.toFloat() / maxCount else 0f },
modifier = Modifier.fillMaxWidth()
)
}
}

View file

@ -0,0 +1,37 @@
package com.roundingmobile.notisaver.presentation.stats
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.roundingmobile.notisaver.domain.model.AppInfo
import com.roundingmobile.notisaver.domain.usecase.ComputeStatisticsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class StatsViewModel @Inject constructor(
computeStatisticsUseCase: ComputeStatisticsUseCase
) : ViewModel() {
val uiState: StateFlow<StatsUiState> = computeStatisticsUseCase()
.map { data ->
if (data.totalCount == 0) StatsUiState.Empty
else StatsUiState.Success(
totalCount = data.totalCount,
topApps = data.appStats.take(10)
)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatsUiState.Loading)
}
sealed interface StatsUiState {
data object Loading : StatsUiState
data object Empty : StatsUiState
data class Success(
val totalCount: Int,
val topApps: List<AppInfo>
) : StatsUiState
}

View file

@ -0,0 +1,286 @@
package com.roundingmobile.notisaver.presentation.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
enum class AppThemeType(val displayName: String) {
SYSTEM("System default"),
WARM_GOLD("Warm Gold"),
OCEAN_BLUE("Ocean Blue"),
FOREST_GREEN("Forest Green"),
ROSE("Rose"),
MIDNIGHT("Midnight"),
CLASSIC("Classic");
}
data class AppThemeColors(
val light: ColorScheme,
val dark: ColorScheme
)
object AppThemes {
fun get(type: AppThemeType): AppThemeColors = when (type) {
AppThemeType.SYSTEM -> systemTheme
AppThemeType.WARM_GOLD -> warmGoldTheme
AppThemeType.OCEAN_BLUE -> oceanBlueTheme
AppThemeType.FOREST_GREEN -> forestGreenTheme
AppThemeType.ROSE -> roseTheme
AppThemeType.MIDNIGHT -> midnightTheme
AppThemeType.CLASSIC -> classicTheme
}
private val warmGoldTheme = AppThemeColors(
light = lightColorScheme(
primary = Color(0xFF8B6914),
onPrimary = Color.White,
primaryContainer = Color(0xFFFFF0C7),
onPrimaryContainer = Color(0xFF2A1F00),
secondary = Color(0xFF6D5D3F),
onSecondary = Color.White,
secondaryContainer = Color(0xFFF8E1BB),
onSecondaryContainer = Color(0xFF261A04),
tertiary = Color(0xFF4E6544),
onTertiary = Color.White,
tertiaryContainer = Color(0xFFD0EBC1),
onTertiaryContainer = Color(0xFF0C2006),
background = Color(0xFFFFF9F0),
onBackground = Color(0xFF1E1B16),
surface = Color(0xFFFFF9F0),
onSurface = Color(0xFF1E1B16),
surfaceVariant = Color(0xFFEDE1CF),
onSurfaceVariant = Color(0xFF4D4639),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFF5DDDA),
onErrorContainer = Color(0xFF410002)
),
dark = darkColorScheme(
primary = Color(0xFFE8C44D),
onPrimary = Color(0xFF3F2E00),
primaryContainer = Color(0xFF5B4300),
onPrimaryContainer = Color(0xFFFFF0C7),
secondary = Color(0xFFDBC5A0),
onSecondary = Color(0xFF3C2F15),
secondaryContainer = Color(0xFF54452A),
onSecondaryContainer = Color(0xFFF8E1BB),
tertiary = Color(0xFFB5CFA7),
onTertiary = Color(0xFF213519),
tertiaryContainer = Color(0xFF374D2E),
onTertiaryContainer = Color(0xFFD0EBC1),
background = Color(0xFF16130E),
onBackground = Color(0xFFEAE1D5),
surface = Color(0xFF16130E),
onSurface = Color(0xFFEAE1D5),
surfaceVariant = Color(0xFF4D4639),
onSurfaceVariant = Color(0xFFD0C5B4),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF5C1617),
onErrorContainer = Color(0xFFFFDAD6)
)
)
private val oceanBlueTheme = AppThemeColors(
light = lightColorScheme(
primary = Color(0xFF0061A4),
onPrimary = Color.White,
primaryContainer = Color(0xFFD1E4FF),
onPrimaryContainer = Color(0xFF001D36),
secondary = Color(0xFF535F70),
onSecondary = Color.White,
tertiary = Color(0xFF6B5778),
background = Color(0xFFF8FDFF),
onBackground = Color(0xFF1A1C1E),
surface = Color(0xFFF8FDFF),
onSurface = Color(0xFF1A1C1E),
surfaceVariant = Color(0xFFDEE3EB),
onSurfaceVariant = Color(0xFF42474E),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFE8D6D8),
onErrorContainer = Color(0xFF410002)
),
dark = darkColorScheme(
primary = Color(0xFF9ECAFF),
onPrimary = Color(0xFF003258),
primaryContainer = Color(0xFF00497D),
onPrimaryContainer = Color(0xFFD1E4FF),
secondary = Color(0xFFBBC7DB),
onSecondary = Color(0xFF253140),
tertiary = Color(0xFFD7BDE4),
background = Color(0xFF0E1418),
onBackground = Color(0xFFE2E2E6),
surface = Color(0xFF0E1418),
onSurface = Color(0xFFE2E2E6),
surfaceVariant = Color(0xFF42474E),
onSurfaceVariant = Color(0xFFC2C7CF),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF4F2022),
onErrorContainer = Color(0xFFFFDAD6)
)
)
private val forestGreenTheme = AppThemeColors(
light = lightColorScheme(
primary = Color(0xFF3A6A1E),
onPrimary = Color.White,
primaryContainer = Color(0xFFBBF396),
onPrimaryContainer = Color(0xFF072100),
secondary = Color(0xFF55624C),
onSecondary = Color.White,
tertiary = Color(0xFF386667),
background = Color(0xFFF8FBF0),
onBackground = Color(0xFF1A1C18),
surface = Color(0xFFF8FBF0),
onSurface = Color(0xFF1A1C18),
surfaceVariant = Color(0xFFE0E4D6),
onSurfaceVariant = Color(0xFF44483E),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFE2D9D3),
onErrorContainer = Color(0xFF410002)
),
dark = darkColorScheme(
primary = Color(0xFFA0D77D),
onPrimary = Color(0xFF143800),
primaryContainer = Color(0xFF235106),
onPrimaryContainer = Color(0xFFBBF396),
secondary = Color(0xFFBECBB0),
onSecondary = Color(0xFF283421),
tertiary = Color(0xFFA0D0D1),
background = Color(0xFF0F1410),
onBackground = Color(0xFFE2E3DB),
surface = Color(0xFF0F1410),
onSurface = Color(0xFFE2E3DB),
surfaceVariant = Color(0xFF44483E),
onSurfaceVariant = Color(0xFFC4C8BA),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF4A2524),
onErrorContainer = Color(0xFFFFDAD6)
)
)
private val roseTheme = AppThemeColors(
light = lightColorScheme(
primary = Color(0xFFB5166B),
onPrimary = Color.White,
primaryContainer = Color(0xFFFFD9E6),
onPrimaryContainer = Color(0xFF3E0021),
secondary = Color(0xFF74565E),
onSecondary = Color.White,
tertiary = Color(0xFF7C5635),
background = Color(0xFFFFF8F8),
onBackground = Color(0xFF201A1B),
surface = Color(0xFFFFF8F8),
onSurface = Color(0xFF201A1B),
surfaceVariant = Color(0xFFF3DDE1),
onSurfaceVariant = Color(0xFF524346),
error = Color(0xFF904A43),
onError = Color.White,
errorContainer = Color(0xFFF5D8D4),
onErrorContainer = Color(0xFF3B0907)
),
dark = darkColorScheme(
primary = Color(0xFFFFB0CC),
onPrimary = Color(0xFF650038),
primaryContainer = Color(0xFF8E0052),
onPrimaryContainer = Color(0xFFFFD9E6),
secondary = Color(0xFFE3BDC6),
onSecondary = Color(0xFF422931),
tertiary = Color(0xFFECBD94),
background = Color(0xFF1A1113),
onBackground = Color(0xFFEDE0E1),
surface = Color(0xFF1A1113),
onSurface = Color(0xFFEDE0E1),
surfaceVariant = Color(0xFF524346),
onSurfaceVariant = Color(0xFFD6C2C5),
error = Color(0xFFFFB4AB),
onError = Color(0xFF561E19),
errorContainer = Color(0xFF5C2D28),
onErrorContainer = Color(0xFFFFDAD6)
)
)
private val midnightTheme = AppThemeColors(
light = lightColorScheme(
primary = Color(0xFF4A5568),
onPrimary = Color.White,
primaryContainer = Color(0xFFD5DADF),
secondary = Color(0xFF5A6270),
onSecondary = Color.White,
background = Color(0xFFF7F8FA),
onBackground = Color(0xFF1A1B1E),
surface = Color(0xFFF7F8FA),
onSurface = Color(0xFF1A1B1E),
surfaceVariant = Color(0xFFE1E2E5),
onSurfaceVariant = Color(0xFF44474B),
error = Color(0xFF8C4A4A),
onError = Color.White,
errorContainer = Color(0xFFDDD5D5),
onErrorContainer = Color(0xFF3B0A0A)
),
dark = darkColorScheme(
primary = Color(0xFFA8B5C8),
onPrimary = Color(0xFF1C2636),
primaryContainer = Color(0xFF323D4D),
secondary = Color(0xFFBEC6D4),
onSecondary = Color(0xFF293340),
background = Color(0xFF000000),
onBackground = Color(0xFFE3E3E6),
surface = Color(0xFF000000),
onSurface = Color(0xFFE3E3E6),
surfaceVariant = Color(0xFF44474B),
onSurfaceVariant = Color(0xFFC4C6CB),
error = Color(0xFFCFA8A8),
onError = Color(0xFF3B0A0A),
errorContainer = Color(0xFF3D2626),
onErrorContainer = Color(0xFFE8D0D0)
)
)
private val classicTheme = AppThemeColors(
light = lightColorScheme(
primary = Color(0xFF555555),
onPrimary = Color.White,
primaryContainer = Color(0xFFE0E0E0),
onPrimaryContainer = Color(0xFF1A1A1A),
secondary = Color(0xFF666666),
onSecondary = Color.White,
background = Color(0xFFFFFFFF),
onBackground = Color(0xFF1A1A1A),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF1A1A1A),
surfaceVariant = Color(0xFFEEEEEE),
onSurfaceVariant = Color(0xFF444444),
error = Color(0xFF8B4545),
onError = Color.White,
errorContainer = Color(0xFFE0D4D4),
onErrorContainer = Color(0xFF370808)
),
dark = darkColorScheme(
primary = Color(0xFFBBBBBB),
onPrimary = Color(0xFF1A1A1A),
primaryContainer = Color(0xFF444444),
onPrimaryContainer = Color(0xFFE0E0E0),
secondary = Color(0xFFAAAAAA),
onSecondary = Color(0xFF1A1A1A),
background = Color(0xFF121212),
onBackground = Color(0xFFE0E0E0),
surface = Color(0xFF121212),
onSurface = Color(0xFFE0E0E0),
surfaceVariant = Color(0xFF333333),
onSurfaceVariant = Color(0xFFBBBBBB),
error = Color(0xFFC4A0A0),
onError = Color(0xFF370808),
errorContainer = Color(0xFF3A2222),
onErrorContainer = Color(0xFFE0D4D4)
)
)
// System theme uses device dynamic colors (handled in Theme.kt), this is fallback
private val systemTheme = warmGoldTheme
}

View file

@ -0,0 +1,11 @@
package com.roundingmobile.notisaver.presentation.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650A4)
val PurpleGrey40 = Color(0xFF625B71)
val Pink40 = Color(0xFF7D5260)

View file

@ -0,0 +1,40 @@
package com.roundingmobile.notisaver.presentation.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.platform.LocalContext
val LocalAppThemeType = compositionLocalOf { AppThemeType.SYSTEM }
@Composable
fun NotiSaverTheme(
themeType: AppThemeType = AppThemeType.SYSTEM,
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = when {
themeType == AppThemeType.SYSTEM && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
else -> {
val theme = AppThemes.get(themeType)
if (darkTheme) theme.dark else theme.light
}
}
CompositionLocalProvider(LocalAppThemeType provides themeType) {
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
}

View file

@ -0,0 +1,31 @@
package com.roundingmobile.notisaver.presentation.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)

View file

@ -0,0 +1,757 @@
package com.roundingmobile.notisaver.presentation.timeline
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FiberNew
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.roundingmobile.notisaver.R
import androidx.compose.foundation.layout.PaddingValues
import com.roundingmobile.notisaver.domain.model.CapturedNotification
import com.roundingmobile.notisaver.domain.model.SortOrder
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
import com.roundingmobile.notisaver.instrumentation.testTrack
import com.roundingmobile.notisaver.presentation.common.EmptyState
import com.roundingmobile.notisaver.presentation.common.SeenDivider
import androidx.compose.ui.draw.clip
import com.roundingmobile.notisaver.presentation.common.SwipeableNotificationItem
import com.roundingmobile.notisaver.presentation.settings.FilterRuleEditorSheet
import com.roundingmobile.notisaver.presentation.settings.FilterRulesViewModel
import kotlinx.coroutines.launch
import com.roundingmobile.notisaver.presentation.common.TimeBlockHeader
import com.roundingmobile.notisaver.util.DateTimeUtils
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun TimelineScreen(
onNotificationClick: (Long) -> Unit,
instrumentation: TestInstrumentation,
modifier: Modifier = Modifier,
viewModel: TimelineViewModel = hiltViewModel(),
filterRulesViewModel: FilterRulesViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val sortOrder by viewModel.sortOrder.collectAsStateWithLifecycle()
var ruleCreationNotification by remember { mutableStateOf<CapturedNotification?>(null) }
val searchActive by viewModel.searchActive.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val filterSheetVisible by viewModel.filterSheetVisible.collectAsStateWithLifecycle()
val filterApps by viewModel.filterApps.collectAsStateWithLifecycle()
val activeFilterCount by viewModel.activeFilterCount.collectAsStateWithLifecycle()
val showNewOnly by viewModel.showNewOnly.collectAsStateWithLifecycle()
// Back button closes search instead of exiting app
BackHandler(enabled = searchActive) {
viewModel.deactivateSearch()
}
var sortMenuExpanded by remember { mutableStateOf(false) }
var showClearAllDialog by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
val coroutineScope = androidx.compose.runtime.rememberCoroutineScope()
Scaffold(
snackbarHost = {
androidx.compose.material3.SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.padding(bottom = 80.dp)
)
},
topBar = {
AnimatedContent(targetState = searchActive, label = "topbar") { isSearching ->
if (isSearching) {
TopAppBar(
expandedHeight = 48.dp,
title = {
TextField(
value = searchQuery,
onValueChange = { viewModel.onSearchQueryChange(it) },
placeholder = { Text(stringResource(R.string.search_hint)) },
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.testTrack(instrumentation, "search_field", searchQuery),
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { viewModel.onSearchQueryChange("") }) {
Icon(Icons.Default.Clear, contentDescription = "Clear")
}
}
}
)
},
navigationIcon = {
IconButton(onClick = { viewModel.deactivateSearch() }) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.cd_back))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
)
} else {
TopAppBar(
expandedHeight = 48.dp,
title = {
Text(
text = stringResource(R.string.nav_timeline),
modifier = Modifier.testTrack(instrumentation, "timeline_title", "Timeline")
)
},
actions = {
IconButton(
onClick = { viewModel.activateSearch() },
modifier = Modifier.testTrack(instrumentation, "btn_search", "Search")
) {
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.cd_search))
}
IconButton(
onClick = { viewModel.showFilterSheet() },
modifier = Modifier.testTrack(instrumentation, "btn_filter", "Filter")
) {
Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.cd_filter))
}
Box {
IconButton(
onClick = { sortMenuExpanded = true },
modifier = Modifier.testTrack(instrumentation, "btn_sort", "Sort: $sortOrder")
) {
Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = stringResource(R.string.cd_sort))
}
SortDropdown(
expanded = sortMenuExpanded,
currentSort = sortOrder,
onSelect = { viewModel.setSortOrder(it); sortMenuExpanded = false },
onClearAll = { showClearAllDialog = true },
onDismiss = { sortMenuExpanded = false }
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
}
},
modifier = modifier
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
// Chip bar: new count + new only filter + active app filters
val state = uiState
val newCount = if (state is TimelineUiState.Success) state.newCount else 0
AnimatedVisibility(visible = (newCount > 0 || activeFilterCount > 0 || showNewOnly) && !searchActive) {
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
// "X new" chip
if (newCount > 0) {
AssistChip(
onClick = {},
label = { Text("$newCount new") },
leadingIcon = {
Icon(
Icons.Default.FiberNew,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
},
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
labelColor = MaterialTheme.colorScheme.onPrimaryContainer
),
modifier = Modifier.testTrack(instrumentation, "chip_new_count", "$newCount")
)
}
// "New only" filter chip
FilterChip(
selected = showNewOnly,
onClick = { viewModel.toggleShowNewOnly() },
label = { Text("New only") },
modifier = Modifier.testTrack(instrumentation, "chip_new_only", "$showNewOnly")
)
// Active app filter chips
val visibleApps = filterApps.filter { it.isVisible }
if (visibleApps.isNotEmpty() && visibleApps.size < filterApps.size) {
visibleApps.forEach { app ->
AssistChip(
onClick = { viewModel.showFilterSheet() },
label = { Text(app.appName, style = MaterialTheme.typography.labelSmall) }
)
}
}
}
}
// Main content
when (state) {
is TimelineUiState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is TimelineUiState.Empty -> {
EmptyState(
modifier = Modifier.testTrack(instrumentation, "empty_state", "No notifications")
)
}
is TimelineUiState.SearchEmpty -> {
EmptyState(
message = stringResource(R.string.empty_search_query, state.query),
modifier = Modifier.testTrack(instrumentation, "search_empty", "No results for ${state.query}")
)
}
is TimelineUiState.Success -> {
TimelineList(
notifications = state.notifications,
lastSeenTimestamp = state.lastSeenTimestamp,
onNotificationClick = onNotificationClick,
onDelete = { notification ->
viewModel.deleteNotification(notification)
coroutineScope.launch {
val result = snackbarHostState.showSnackbar(
message = "Notification deleted",
actionLabel = "Undo",
duration = androidx.compose.material3.SnackbarDuration.Short
)
if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
viewModel.undoDelete()
}
}
},
onBookmark = { id, bookmarked -> viewModel.bookmarkNotification(id, bookmarked) },
onCreateRule = { notification -> ruleCreationNotification = notification },
onDeleteMultiple = { items, label ->
viewModel.deleteMultiple(items)
coroutineScope.launch {
snackbarHostState.showSnackbar("$label deleted")
}
},
isSearching = searchActive,
instrumentation = instrumentation
)
}
}
}
}
// Filter rule editor sheet
ruleCreationNotification?.let { notification ->
FilterRuleEditorSheet(
initialRule = FilterRulesViewModel.prefillFromNotification(notification),
onSave = { rule ->
filterRulesViewModel.createRule(rule)
ruleCreationNotification = null
},
onDismiss = { ruleCreationNotification = null }
)
}
// Clear all confirmation dialog
if (showClearAllDialog) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showClearAllDialog = false },
title = { Text("Clear all notifications") },
text = { Text("This will permanently delete all captured notifications. This cannot be undone.") },
confirmButton = {
androidx.compose.material3.TextButton(onClick = {
viewModel.clearAll()
showClearAllDialog = false
}) { Text("Clear all", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = { showClearAllDialog = false }) {
Text("Cancel")
}
}
)
}
// Refresh last-seen timestamp each time the activity resumes
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
androidx.compose.runtime.DisposableEffect(lifecycleOwner) {
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
viewModel.refreshLastSeen()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
LaunchedEffect(searchActive) {
if (searchActive) focusRequester.requestFocus()
}
if (filterSheetVisible) {
FilterBottomSheet(
apps = filterApps,
onToggle = { viewModel.toggleAppFilter(it) },
onSelectAll = { viewModel.selectAllApps() },
onDeselectAll = { viewModel.deselectAllApps() },
onDismiss = { viewModel.hideFilterSheet() },
instrumentation = instrumentation
)
}
}
@Composable
private fun SortDropdown(
expanded: Boolean,
currentSort: SortOrder,
onSelect: (SortOrder) -> Unit,
onClearAll: () -> Unit,
onDismiss: () -> Unit
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
SortOrder.entries.forEach { order ->
val label = when (order) {
SortOrder.NEWEST_FIRST -> stringResource(R.string.sort_newest)
SortOrder.OLDEST_FIRST -> stringResource(R.string.sort_oldest)
SortOrder.BY_APP -> stringResource(R.string.sort_by_app)
}
DropdownMenuItem(
text = {
Text(
text = label,
color = if (order == currentSort) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface
)
},
onClick = { onSelect(order) }
)
}
androidx.compose.material3.HorizontalDivider()
DropdownMenuItem(
text = { Text("Clear all", color = MaterialTheme.colorScheme.error) },
leadingIcon = {
Icon(
Icons.Default.Clear,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
onClick = { onDismiss(); onClearAll() }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FilterBottomSheet(
apps: List<FilterAppInfo>,
onToggle: (String) -> Unit,
onSelectAll: () -> Unit,
onDeselectAll: () -> Unit,
onDismiss: () -> Unit,
instrumentation: TestInstrumentation
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(modifier = Modifier.padding(bottom = 32.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.filter_title), style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
androidx.compose.material3.TextButton(onClick = onSelectAll) {
Text(stringResource(R.string.filter_select_all))
}
androidx.compose.material3.TextButton(onClick = onDeselectAll) {
Text(stringResource(R.string.filter_deselect_all))
}
}
}
apps.forEach { app ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.testTrack(instrumentation, "filter_app_${app.packageName}", "${app.appName} visible=${app.isVisible}"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
androidx.compose.material3.Checkbox(
checked = app.isVisible,
onCheckedChange = { onToggle(app.packageName) }
)
com.roundingmobile.notisaver.presentation.common.AppIcon(
packageName = app.packageName,
appName = app.appName,
size = 32.dp,
modifier = Modifier.padding(start = 4.dp)
)
Text(app.appName, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(start = 8.dp))
}
AssistChip(onClick = {}, label = { Text("${app.count}") })
}
}
}
}
}
@Composable
private fun TimelineList(
notifications: List<CapturedNotification>,
lastSeenTimestamp: Long,
onNotificationClick: (Long) -> Unit,
onDelete: (CapturedNotification) -> Unit,
onBookmark: (Long, Boolean) -> Unit,
onCreateRule: (CapturedNotification) -> Unit,
onDeleteMultiple: (List<CapturedNotification>, String) -> Unit,
isSearching: Boolean,
instrumentation: TestInstrumentation,
modifier: Modifier = Modifier
) {
val bottomPad = 78.dp
val dayGroups = DateTimeUtils.groupByDay(notifications)
val expandedDays = remember { mutableStateOf(setOf("Today")) }
val expandedAppGroups = remember { mutableStateOf(setOf<String>()) }
var globalIndex = 0
var seenDividerInserted = false
LazyColumn(
modifier = modifier
.fillMaxSize()
.testTrack(instrumentation, "timeline_list", "count=${notifications.size}"),
contentPadding = PaddingValues(bottom = bottomPad)
) {
dayGroups.forEach { dayGroup ->
val isDayExpanded = isSearching || dayGroup.key in expandedDays.value
// Day header
item(key = "day_${dayGroup.key}") {
DayHeader(
dayKey = dayGroup.key,
timestamp = dayGroup.representativeTimestamp,
count = dayGroup.notifications.size,
isExpanded = isDayExpanded,
onClick = {
expandedDays.value = if (isDayExpanded) expandedDays.value - dayGroup.key
else expandedDays.value + dayGroup.key
},
modifier = Modifier.animateItem()
)
}
if (!isDayExpanded) {
globalIndex += dayGroup.notifications.size
return@forEach
}
// Within expanded day: group consecutive same-app
if (isSearching) {
// Search: flat list, no grouping
dayGroup.notifications.forEach { notification ->
val idx = globalIndex++
item(key = notification.id) {
SwipeableNotificationItem(
notification = notification,
onClick = { onNotificationClick(notification.id) },
onDelete = { onDelete(notification) },
onBookmark = { bookmarked -> onBookmark(notification.id, bookmarked) },
onCreateRule = { onCreateRule(notification) },
modifier = Modifier
.animateItem()
.padding(horizontal = 8.dp, vertical = 2.dp)
.testTrack(instrumentation, "notif_item_$idx", "${notification.appName}: ${notification.title}")
)
}
}
} else {
val consecutiveGroups = DateTimeUtils.groupConsecutiveByApp(dayGroup.notifications)
consecutiveGroups.forEachIndexed { groupIdx, group ->
if (group.isGroup) {
// Consecutive app group (2+)
val groupKey = "${dayGroup.key}_${group.packageName}_$groupIdx"
val isGroupExpanded = groupKey in expandedAppGroups.value
item(key = "appgroup_$groupKey") {
ConsecutiveAppGroupHeader(
group = group,
isExpanded = isGroupExpanded,
onClick = {
expandedAppGroups.value = if (isGroupExpanded) expandedAppGroups.value - groupKey
else expandedAppGroups.value + groupKey
},
onSwipeDelete = {
onDeleteMultiple(group.notifications, "${group.count} ${group.appName} notifications")
},
modifier = Modifier.animateItem().padding(horizontal = 8.dp, vertical = 2.dp)
)
}
if (isGroupExpanded) {
group.notifications.forEach { notification ->
val idx = globalIndex++
val isNew = notification.timestamp > lastSeenTimestamp
if (!isNew && !seenDividerInserted && lastSeenTimestamp > 0 && dayGroup.key == "Today") {
seenDividerInserted = true
item(key = "seen_divider") { SeenDivider(modifier = Modifier.animateItem()) }
}
item(key = notification.id) {
SwipeableNotificationItem(
notification = notification,
onClick = { onNotificationClick(notification.id) },
onDelete = { onDelete(notification) },
onBookmark = { bookmarked -> onBookmark(notification.id, bookmarked) },
onCreateRule = { onCreateRule(notification) },
modifier = Modifier
.animateItem()
.padding(start = 24.dp, end = 8.dp, top = 1.dp, bottom = 1.dp)
.testTrack(instrumentation, "notif_item_$idx", "${notification.appName}: ${notification.title}")
)
}
}
} else {
globalIndex += group.count
}
} else {
// Single notification
val notification = group.latest
val idx = globalIndex++
val isNew = notification.timestamp > lastSeenTimestamp
if (!isNew && !seenDividerInserted && lastSeenTimestamp > 0 && dayGroup.key == "Today") {
seenDividerInserted = true
item(key = "seen_divider") { SeenDivider(modifier = Modifier.animateItem()) }
}
item(key = notification.id) {
SwipeableNotificationItem(
notification = notification,
onClick = { onNotificationClick(notification.id) },
onDelete = { onDelete(notification) },
onBookmark = { bookmarked -> onBookmark(notification.id, bookmarked) },
onCreateRule = { onCreateRule(notification) },
modifier = Modifier
.animateItem()
.padding(horizontal = 8.dp, vertical = 2.dp)
.testTrack(instrumentation, "notif_item_$idx", "${notification.appName}: ${notification.title}")
)
}
}
}
}
}
}
}
@Composable
private fun DayHeader(
dayKey: String,
timestamp: Long,
count: Int,
isExpanded: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val label = when (dayKey) {
"Today" -> "Today"
"Yesterday" -> "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"
}
}
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
if (!isExpanded) {
Text(
"$count",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 4.dp)
)
}
Icon(
if (isExpanded) androidx.compose.material.icons.Icons.Default.Close
else Icons.Default.FilterList,
contentDescription = if (isExpanded) "Collapse" else "Expand",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConsecutiveAppGroupHeader(
group: com.roundingmobile.notisaver.util.ConsecutiveGroup,
isExpanded: Boolean,
onClick: () -> Unit,
onSwipeDelete: () -> Unit,
modifier: Modifier = Modifier
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
if (value == SwipeToDismissBoxValue.EndToStart) { onSwipeDelete(); true } else false
}
)
SwipeToDismissBox(
state = dismissState,
backgroundContent = {
Box(
modifier = Modifier.fillMaxSize().padding(vertical = 2.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.CenterEnd
) {
Text(
"Delete ${group.count}",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = 20.dp)
)
}
},
enableDismissFromStartToEnd = false,
enableDismissFromEndToStart = true,
modifier = modifier
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
onClick = onClick
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
com.roundingmobile.notisaver.presentation.common.AppIcon(
packageName = group.packageName,
appName = group.appName,
size = 40.dp
)
Column(
modifier = Modifier.weight(1f).padding(start = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(group.appName, style = MaterialTheme.typography.labelMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(10.dp))
.padding(horizontal = 6.dp, vertical = 1.dp)
) {
Text("${group.count}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimaryContainer)
}
}
Text(
com.roundingmobile.notisaver.presentation.common.formatTimeShort(group.latest.timestamp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
group.latest.title?.takeIf { it.isNotBlank() }?.let { title ->
Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
}
group.latest.text?.takeIf { it.isNotBlank() }?.let { text ->
Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
}
}
Icon(
if (isExpanded) androidx.compose.material.icons.Icons.Default.Close else Icons.Default.FilterList,
contentDescription = if (isExpanded) "Collapse" else "Expand",
modifier = Modifier.padding(start = 4.dp).size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View file

@ -0,0 +1,256 @@
package com.roundingmobile.notisaver.presentation.timeline
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.roundingmobile.notisaver.data.local.db.dao.AppFilterDao
import com.roundingmobile.notisaver.data.local.db.entity.AppFilterEntity
import com.roundingmobile.notisaver.domain.model.CapturedNotification
import com.roundingmobile.notisaver.domain.model.SortOrder
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TimelineViewModel @Inject constructor(
private val repository: NotificationRepository,
private val appFilterDao: AppFilterDao,
private val prefs: SharedPreferences,
@ApplicationContext private val context: Context
) : ViewModel() {
// --- New-since-last-open ---
private val _lastSeenTimestamp = MutableStateFlow(prefs.getLong(KEY_LAST_SEEN, 0L))
val lastSeenTimestamp: StateFlow<Long> = _lastSeenTimestamp.asStateFlow()
private val _showNewOnly = MutableStateFlow(false)
val showNewOnly: StateFlow<Boolean> = _showNewOnly.asStateFlow()
// --- Search ---
private val _searchActive = MutableStateFlow(false)
val searchActive: StateFlow<Boolean> = _searchActive.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
// --- Sort ---
private val _sortOrder = MutableStateFlow(SortOrder.NEWEST_FIRST)
val sortOrder: StateFlow<SortOrder> = _sortOrder.asStateFlow()
// --- Filter ---
private val _filteredOutPackages = MutableStateFlow<Set<String>>(emptySet())
val filteredOutPackages: StateFlow<Set<String>> = _filteredOutPackages.asStateFlow()
// --- Filter bottom sheet ---
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?>()
init {
viewModelScope.launch {
_filteredOutPackages.value = appFilterDao.getHiddenFilterPackages().toSet()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private val allNotifications = _sortOrder.flatMapLatest { order ->
repository.getTimeline(order)
}
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
private val searchResults = _searchQuery.debounce(300).flatMapLatest { query ->
if (query.isBlank()) allNotifications
else repository.search(query)
}
val filterApps: StateFlow<List<FilterAppInfo>> = repository.getDistinctApps()
.combine(_filteredOutPackages) { apps, filtered ->
apps.map { app ->
FilterAppInfo(
packageName = app.packageName,
appName = resolveAppName(app.packageName),
count = app.notificationCount,
isVisible = app.packageName !in filtered,
icon = resolveAppIcon(app.packageName)
)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val uiState: StateFlow<TimelineUiState> = combine(
searchResults,
_filteredOutPackages,
_searchQuery,
_lastSeenTimestamp,
_showNewOnly
) { notifications, filtered, query, lastSeen, newOnly ->
var displayList = if (filtered.isEmpty()) notifications
else notifications.filter { it.packageName !in filtered }
if (newOnly) {
displayList = displayList.filter { it.timestamp > lastSeen }
}
val newCount = displayList.count { it.timestamp > lastSeen }
when {
query.isNotBlank() && displayList.isEmpty() -> TimelineUiState.SearchEmpty(query)
displayList.isEmpty() -> TimelineUiState.Empty
else -> TimelineUiState.Success(
notifications = displayList,
newCount = newCount,
lastSeenTimestamp = lastSeen
)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TimelineUiState.Loading)
val activeFilterCount: StateFlow<Int> = _filteredOutPackages
.map { it.size }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
// --- Delete / Undo ---
private var lastDeleted: CapturedNotification? = null
fun deleteNotification(notification: CapturedNotification) {
lastDeleted = notification
viewModelScope.launch {
repository.deleteById(notification.id)
}
}
fun undoDelete() {
lastDeleted?.let { notification ->
viewModelScope.launch {
repository.insert(notification.copy(id = 0))
}
lastDeleted = null
}
}
fun clearAll() {
viewModelScope.launch { repository.deleteAll() }
}
fun deleteMultiple(notifications: List<CapturedNotification>) {
viewModelScope.launch {
notifications.forEach { repository.deleteById(it.id) }
}
}
fun bookmarkNotification(id: Long, bookmarked: Boolean) {
viewModelScope.launch { repository.setBookmarked(id, bookmarked) }
}
// --- Actions ---
fun refreshLastSeen() {
_lastSeenTimestamp.value = prefs.getLong(KEY_LAST_SEEN, 0L)
}
fun markAsSeen() {
val now = System.currentTimeMillis()
prefs.edit().putLong(KEY_LAST_SEEN, now).apply()
_lastSeenTimestamp.value = now
}
fun toggleShowNewOnly() {
_showNewOnly.value = !_showNewOnly.value
}
fun activateSearch() { _searchActive.value = true }
fun deactivateSearch() {
_searchActive.value = false
_searchQuery.value = ""
}
fun onSearchQueryChange(query: String) { _searchQuery.value = query }
fun setSortOrder(order: SortOrder) { _sortOrder.value = order }
fun showFilterSheet() { _filterSheetVisible.value = true }
fun hideFilterSheet() { _filterSheetVisible.value = false }
fun toggleAppFilter(packageName: String) {
val current = _filteredOutPackages.value
_filteredOutPackages.value = if (packageName in current) current - packageName
else current + packageName
viewModelScope.launch {
val isVisible = packageName !in _filteredOutPackages.value
appFilterDao.insert(AppFilterEntity(packageName, isVisible))
}
}
fun selectAllApps() {
_filteredOutPackages.value = emptySet()
viewModelScope.launch { appFilterDao.deleteAll() }
}
fun deselectAllApps() {
val all = filterApps.value.map { it.packageName }.toSet()
_filteredOutPackages.value = all
viewModelScope.launch {
appFilterDao.insertAll(all.map { AppFilterEntity(it, false) })
}
}
fun resolveAppName(packageName: String): String {
return appNameCache.getOrPut(packageName) {
try {
val pm = context.packageManager
val appInfo = pm.getApplicationInfo(packageName, 0)
pm.getApplicationLabel(appInfo).toString()
} catch (_: PackageManager.NameNotFoundException) {
packageName.substringAfterLast('.').replaceFirstChar { it.uppercase() }
}
}
}
fun resolveAppIcon(packageName: String): Drawable? {
return appIconCache.getOrPut(packageName) {
try { context.packageManager.getApplicationIcon(packageName) }
catch (_: PackageManager.NameNotFoundException) { null }
}
}
companion object {
const val KEY_LAST_SEEN = "last_seen_timestamp"
}
}
data class FilterAppInfo(
val packageName: String,
val appName: String,
val count: Int,
val isVisible: Boolean,
val icon: Drawable?
)
sealed interface TimelineUiState {
data object Loading : TimelineUiState
data object Empty : TimelineUiState
data class SearchEmpty(val query: String) : TimelineUiState
data class Success(
val notifications: List<CapturedNotification>,
val newCount: Int,
val lastSeenTimestamp: Long
) : TimelineUiState
}

View file

@ -0,0 +1,58 @@
package com.roundingmobile.notisaver.security
import android.app.KeyguardManager
import android.content.Context
import android.content.SharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppLockManager @Inject constructor(
@ApplicationContext private val context: Context,
private val prefs: SharedPreferences
) {
val isLockEnabled: Boolean
get() = prefs.getBoolean(KEY_LOCK_ENABLED, false)
val isDeviceSecure: Boolean
get() {
val km = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
return km.isDeviceSecure
}
var lastBackgroundTime: Long = 0L
private set
val lockTimeoutMs: Long
get() = prefs.getLong(KEY_LOCK_TIMEOUT, DEFAULT_TIMEOUT_MS)
fun enableLock() {
prefs.edit().putBoolean(KEY_LOCK_ENABLED, true).apply()
}
fun disableLock() {
prefs.edit().putBoolean(KEY_LOCK_ENABLED, false).apply()
}
fun onBackground() {
lastBackgroundTime = System.currentTimeMillis()
}
fun onUnlocked() {
lastBackgroundTime = System.currentTimeMillis()
}
fun shouldLock(): Boolean {
if (!isLockEnabled) return false
if (!isDeviceSecure) return false
if (lastBackgroundTime == 0L) return true
return System.currentTimeMillis() - lastBackgroundTime > lockTimeoutMs
}
companion object {
private const val KEY_LOCK_ENABLED = "lock_enabled_v2"
private const val KEY_LOCK_TIMEOUT = "lock_timeout_ms"
private const val DEFAULT_TIMEOUT_MS = 30_000L
}
}

View file

@ -0,0 +1,82 @@
package com.roundingmobile.notisaver.security
import android.app.Notification
import android.content.SharedPreferences
import android.service.notification.StatusBarNotification
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NotificationFilter @Inject constructor(
private val prefs: SharedPreferences
) {
val filterSystemEnabled: Boolean
get() = prefs.getBoolean(KEY_FILTER_SYSTEM, true)
val collapseRapidEnabled: Boolean
get() = prefs.getBoolean(KEY_COLLAPSE_RAPID, true)
val collapseWindowMs: Long
get() = prefs.getLong(KEY_COLLAPSE_WINDOW, 60_000L)
fun setFilterSystem(enabled: Boolean) {
prefs.edit().putBoolean(KEY_FILTER_SYSTEM, enabled).apply()
}
fun setCollapseRapid(enabled: Boolean) {
prefs.edit().putBoolean(KEY_COLLAPSE_RAPID, enabled).apply()
}
fun setCollapseWindow(ms: Long) {
prefs.edit().putLong(KEY_COLLAPSE_WINDOW, ms).apply()
}
fun shouldIgnore(sbn: StatusBarNotification): Boolean {
if (!filterSystemEnabled) return false
// Auto-ignore packages
if (sbn.packageName in AUTO_IGNORE_PACKAGES) return true
val notification = sbn.notification
val flags = notification.flags
// Ignore group summary notifications (e.g. WhatsApp sends both individual + summary)
if (flags and Notification.FLAG_GROUP_SUMMARY != 0) return true
// Ignore ongoing/no-clear (charging, VPN, USB, media transport)
if (flags and Notification.FLAG_ONGOING_EVENT != 0) return true
if (flags and Notification.FLAG_NO_CLEAR != 0) return true
// Ignore spammy categories
val category = notification.category
if (category in IGNORE_CATEGORIES) return true
return false
}
companion object {
const val KEY_FILTER_SYSTEM = "filter_system_notifications"
const val KEY_COLLAPSE_RAPID = "collapse_rapid_updates"
const val KEY_COLLAPSE_WINDOW = "collapse_window_ms"
val AUTO_IGNORE_PACKAGES = setOf(
"android",
"com.android.systemui",
"com.android.providers.downloads",
"com.android.vending", // Play Store update progress
"com.android.bluetooth",
"com.android.nfc",
"com.android.shell",
"com.android.server.telecom"
)
val IGNORE_CATEGORIES = setOf(
Notification.CATEGORY_PROGRESS,
Notification.CATEGORY_TRANSPORT,
Notification.CATEGORY_SERVICE,
Notification.CATEGORY_SYSTEM,
Notification.CATEGORY_STATUS
)
}
}

View file

@ -0,0 +1,168 @@
package com.roundingmobile.notisaver.service
import android.content.pm.PackageManager
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
import com.roundingmobile.notisaver.data.local.db.dao.HiddenAppDao
import com.roundingmobile.notisaver.domain.model.FilterAction
import com.roundingmobile.notisaver.domain.repository.FilterRuleRepository
import com.roundingmobile.notisaver.domain.repository.NotificationRepository
import com.roundingmobile.notisaver.domain.usecase.FilterRuleEngine
import com.roundingmobile.notisaver.instrumentation.TestInstrumentation
import com.roundingmobile.notisaver.security.NotificationFilter
import com.roundingmobile.notisaver.util.NotificationParser
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@AndroidEntryPoint
class NotificationCaptureService : NotificationListenerService() {
@Inject lateinit var repository: NotificationRepository
@Inject lateinit var hiddenAppDao: HiddenAppDao
@Inject lateinit var instrumentation: TestInstrumentation
@Inject lateinit var filter: NotificationFilter
@Inject lateinit var filterRuleRepository: FilterRuleRepository
@Inject lateinit var filterRuleEngine: FilterRuleEngine
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// In-memory dedup: notification key → timestamp of last processing
private val recentKeys = ConcurrentHashMap<String, Long>()
override fun onNotificationPosted(sbn: StatusBarNotification) {
if (sbn.packageName == packageName) return
// System/spam filter
if (filter.shouldIgnore(sbn)) return
// Reject rapid-fire duplicate callbacks for the same notification
val dedupKey = "${sbn.packageName}|${sbn.id}|${sbn.tag}"
val now = System.currentTimeMillis()
val lastSeen = recentKeys.put(dedupKey, now)
if (lastSeen != null && now - lastSeen < DEDUP_WINDOW_MS) return
// Prune old entries periodically
if (recentKeys.size > 100) {
recentKeys.entries.removeAll { now - it.value > DEDUP_WINDOW_MS }
}
serviceScope.launch {
try {
if (hiddenAppDao.isHidden(sbn.packageName)) return@launch
val appName = resolveAppName(sbn.packageName)
val parsed = NotificationParser.parse(sbn, appName)
// Check user-defined filter rules
val ruleAction = filterRuleEngine.evaluate(
filterRuleRepository.getEnabledRules(),
sbn.packageName, appName, parsed.title, parsed.text
)
if (ruleAction == FilterAction.SUPPRESS) {
Log.d(TAG, "Suppressed by filter rule: pkg=${sbn.packageName} title=${parsed.title}")
return@launch
}
// Check for existing notification with same id+tag+package → update in place
val previous = repository.findPrevious(
parsed.notificationId, parsed.notificationTag, parsed.packageName
)
if (previous != null) {
repository.update(previous.copy(
text = parsed.text ?: previous.text,
bigText = parsed.bigText ?: previous.bigText,
timestamp = parsed.timestamp,
isUpdate = true
))
instrumentation.reportEvent(
"notification_updated",
mapOf("package" to sbn.packageName, "id" to previous.id.toString())
)
return@launch
}
// Collapse rapid updates: same package + same title within window
if (filter.collapseRapidEnabled && !parsed.title.isNullOrBlank()) {
val since = System.currentTimeMillis() - filter.collapseWindowMs
val recent = repository.findRecentByPackageAndTitle(
parsed.packageName, parsed.title, since
)
if (recent != null) {
repository.update(recent.copy(
text = parsed.text ?: recent.text,
bigText = parsed.bigText ?: recent.bigText,
timestamp = parsed.timestamp,
isUpdate = true
))
instrumentation.reportEvent(
"notification_collapsed",
mapOf("package" to sbn.packageName, "title" to parsed.title, "id" to recent.id.toString())
)
return@launch
}
}
val insertedId = repository.insert(parsed)
instrumentation.reportEvent(
"notification_captured",
mapOf(
"package" to sbn.packageName,
"title" to (parsed.title ?: ""),
"id" to insertedId.toString()
)
)
} catch (e: Exception) {
Log.e(TAG, "Error capturing notification", e)
}
}
}
override fun onNotificationRemoved(sbn: StatusBarNotification) {
if (sbn.packageName == packageName) return
if (filter.shouldIgnore(sbn)) return
serviceScope.launch {
try {
val previous = repository.findPrevious(sbn.id, sbn.tag, sbn.packageName)
if (previous != null) {
val now = System.currentTimeMillis()
val delayMs = now - previous.timestamp
repository.markRemoved(previous.id, now, delayMs)
instrumentation.reportEvent(
"notification_removed",
mapOf("id" to previous.id.toString(), "delay_ms" to delayMs.toString())
)
}
} catch (e: Exception) {
Log.e(TAG, "Error handling notification removal", e)
}
}
}
override fun onDestroy() {
super.onDestroy()
serviceScope.cancel()
}
private fun resolveAppName(packageName: String): String {
return try {
val pm = applicationContext.packageManager
val appInfo = pm.getApplicationInfo(packageName, 0)
pm.getApplicationLabel(appInfo).toString()
} catch (_: PackageManager.NameNotFoundException) {
packageName
}
}
companion object {
private const val TAG = "NotificationCapture"
private const val DEDUP_WINDOW_MS = 1000L
}
}

View file

@ -0,0 +1,113 @@
package com.roundingmobile.notisaver.util
import android.content.SharedPreferences
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
enum class DateFormatOption(val label: String, val pattern: String?) {
SYSTEM("System default", null),
ISO("YYYY-MM-DD", "yyyy-MM-dd"),
DMY_SLASH("DD/MM/YYYY", "dd/MM/yyyy"),
MDY_SLASH("MM/DD/YYYY", "MM/dd/yyyy"),
DMY_TEXT("DD MMM YYYY", "dd MMM yyyy"),
DMY_DOT("DD.MM.YYYY", "dd.MM.yyyy");
}
enum class TimeFormatOption(val label: String, val pattern: String?) {
SYSTEM("System default", null),
H24("24-hour", "HH:mm"),
H12("12-hour", "h:mm a");
}
@Singleton
class AppDateFormatter @Inject constructor(
private val prefs: SharedPreferences
) {
val dateFormat: DateFormatOption
get() = runCatching {
DateFormatOption.valueOf(prefs.getString(KEY_DATE_FORMAT, DateFormatOption.SYSTEM.name)!!)
}.getOrDefault(DateFormatOption.SYSTEM)
val timeFormat: TimeFormatOption
get() = runCatching {
TimeFormatOption.valueOf(prefs.getString(KEY_TIME_FORMAT, TimeFormatOption.SYSTEM.name)!!)
}.getOrDefault(TimeFormatOption.SYSTEM)
fun setDateFormat(option: DateFormatOption) {
prefs.edit().putString(KEY_DATE_FORMAT, option.name).apply()
}
fun setTimeFormat(option: TimeFormatOption) {
prefs.edit().putString(KEY_TIME_FORMAT, option.name).apply()
}
fun formatTime(timestamp: Long): String {
val fmt = when (val opt = timeFormat) {
TimeFormatOption.SYSTEM -> DateFormat.getTimeInstance(DateFormat.SHORT)
else -> SimpleDateFormat(opt.pattern, Locale.getDefault())
}
return fmt.format(Date(timestamp))
}
fun formatDate(timestamp: Long): String {
val fmt = when (val opt = dateFormat) {
DateFormatOption.SYSTEM -> DateFormat.getDateInstance(DateFormat.MEDIUM)
else -> SimpleDateFormat(opt.pattern, Locale.getDefault())
}
return fmt.format(Date(timestamp))
}
fun formatDateTime(timestamp: Long): String {
return "${formatDate(timestamp)} ${formatTime(timestamp)}"
}
/** Format for timeline: time only if today, date otherwise */
fun formatTimeline(timestamp: Long): String {
val now = System.currentTimeMillis()
val dayBoundary = dayBoundary(Calendar.getInstance())
return if (timestamp >= dayBoundary) formatTime(timestamp)
else formatDate(timestamp)
}
/** Day header label: "Today", "Yesterday", or formatted date */
fun dayHeaderLabel(dayKey: String, timestamp: Long): String {
return when (dayKey) {
"Today", "Yesterday" -> dayKey
else -> {
val cal = Calendar.getInstance().apply { timeInMillis = timestamp }
val dayName = SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(timestamp))
val date = formatDate(timestamp)
"$dayName $date"
}
}
}
fun previewDate(): String = formatDate(System.currentTimeMillis())
fun previewTime(): String = formatTime(System.currentTimeMillis())
companion object {
const val KEY_DATE_FORMAT = "date_format"
const val KEY_TIME_FORMAT = "time_format"
/** Day boundary is 2:00 AM, not midnight */
private const val DAY_BOUNDARY_HOUR = 2
fun dayBoundary(cal: Calendar): Long {
val c = cal.clone() as Calendar
c.set(Calendar.HOUR_OF_DAY, DAY_BOUNDARY_HOUR)
c.set(Calendar.MINUTE, 0)
c.set(Calendar.SECOND, 0)
c.set(Calendar.MILLISECOND, 0)
// If current time is before 2 AM, the boundary is yesterday's 2 AM
if (cal.get(Calendar.HOUR_OF_DAY) < DAY_BOUNDARY_HOUR) {
c.add(Calendar.DAY_OF_YEAR, -1)
}
return c.timeInMillis
}
}
}

View file

@ -0,0 +1,98 @@
package com.roundingmobile.notisaver.util
import com.roundingmobile.notisaver.domain.model.CapturedNotification
import java.util.Calendar
object DateTimeUtils {
private const val DAY_BOUNDARY_HOUR = 2
private const val ONE_DAY = 24 * 60 * 60 * 1000L
/**
* Group notifications by day with 2 AM boundary.
* Returns list of (dayKey, notifications) where dayKey is "Today", "Yesterday", or a date key.
* Notifications within each day are ordered by timestamp descending.
*/
fun groupByDay(notifications: List<CapturedNotification>): List<DayGroup> {
if (notifications.isEmpty()) return emptyList()
val now = Calendar.getInstance()
val todayBoundary = AppDateFormatter.dayBoundary(now)
val yesterdayBoundary = todayBoundary - ONE_DAY
val groups = linkedMapOf<String, MutableList<CapturedNotification>>()
for (notification in notifications) {
val label = when {
notification.timestamp >= todayBoundary -> "Today"
notification.timestamp >= yesterdayBoundary -> "Yesterday"
else -> dayKeyFor(notification.timestamp)
}
groups.getOrPut(label) { mutableListOf() }.add(notification)
}
return groups.map { (key, items) ->
DayGroup(
key = key,
notifications = items.toList(),
representativeTimestamp = items.first().timestamp
)
}
}
/**
* Within a day's notifications (ordered by time), group consecutive same-app notifications.
* Only groups of 2+ get collapsed. Single items stay individual.
*/
fun groupConsecutiveByApp(notifications: List<CapturedNotification>): List<ConsecutiveGroup> {
if (notifications.isEmpty()) return emptyList()
val result = mutableListOf<ConsecutiveGroup>()
var currentPkg = notifications[0].packageName
var currentGroup = mutableListOf(notifications[0])
for (i in 1 until notifications.size) {
val n = notifications[i]
if (n.packageName == currentPkg) {
currentGroup.add(n)
} else {
result.add(ConsecutiveGroup(currentGroup.toList()))
currentPkg = n.packageName
currentGroup = mutableListOf(n)
}
}
result.add(ConsecutiveGroup(currentGroup.toList()))
return result
}
private fun dayKeyFor(timestamp: Long): String {
val cal = Calendar.getInstance().apply { timeInMillis = timestamp }
// Shift back if before 2 AM (belongs to previous day)
if (cal.get(Calendar.HOUR_OF_DAY) < DAY_BOUNDARY_HOUR) {
cal.add(Calendar.DAY_OF_YEAR, -1)
}
return "day_${cal.get(Calendar.YEAR)}_${cal.get(Calendar.DAY_OF_YEAR)}"
}
// Keep old method for backward compat
fun groupByTimeBlock(notifications: List<CapturedNotification>): List<Pair<String, List<CapturedNotification>>> {
return groupByDay(notifications).map { it.key to it.notifications }
}
}
data class DayGroup(
val key: String,
val notifications: List<CapturedNotification>,
val representativeTimestamp: Long
)
data class ConsecutiveGroup(
val notifications: List<CapturedNotification>
) {
val isGroup: Boolean get() = notifications.size > 1
val count: Int get() = notifications.size
val latest: CapturedNotification get() = notifications.first()
val packageName: String get() = latest.packageName
val appName: String get() = latest.appName
}

View file

@ -0,0 +1,65 @@
package com.roundingmobile.notisaver.util
import android.app.Notification
import android.service.notification.StatusBarNotification
import com.roundingmobile.notisaver.domain.model.CapturedNotification
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object NotificationParser {
private val json = Json { ignoreUnknownKeys = true }
fun parse(sbn: StatusBarNotification, appName: String): CapturedNotification {
val notification = sbn.notification
val extras = notification.extras
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
val text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()
val bigText = extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString()
val category = notification.category
val actions = notification.actions?.map { it.title.toString() } ?: emptyList()
val actionsJson = if (actions.isNotEmpty()) json.encodeToString(actions) else null
val extrasMap = buildMap<String, String> {
extras.keySet().forEach { key ->
try {
val value = extras.get(key)
if (value != null && value !is android.graphics.Bitmap && value !is android.os.Parcelable) {
put(key, value.toString())
}
} catch (_: Exception) {
// Skip unreadable extras
}
}
}
val extrasJson = if (extrasMap.isNotEmpty()) json.encodeToString(extrasMap) else null
val now = System.currentTimeMillis()
return CapturedNotification(
id = 0,
packageName = sbn.packageName,
appName = appName,
title = title,
text = text,
bigText = bigText,
category = category,
priority = notification.priority,
timestamp = sbn.postTime,
notificationId = sbn.id,
notificationTag = sbn.tag,
isUpdate = false,
previousVersionId = null,
isRemoved = false,
removedAt = null,
removalDelayMs = null,
isBookmarked = false,
extrasJson = extrasJson,
actionsJson = actionsJson,
iconUri = null,
createdAt = now
)
}
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Some files were not shown because too many files have changed in this diff Show more