From 62617492b8db0cc57c7b93a956f2e44e68e3b3b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 20:08:02 +0000 Subject: [PATCH] alpha.17: Brick app-blocking module (NFC/time/manual modes, allowances, block wall), 3s vault hold, screen-time sync perf Co-Authored-By: Claude Opus 4.8 --- README.md | 8 +- app/build.gradle.kts | 5 +- .../kotlin/com/lifeos/app/MainActivity.kt | 8 + .../kotlin/com/lifeos/app/ui/LifeOsApp.kt | 2 + .../com/lifeos/app/ui/screen/HomeScreen.kt | 17 +- .../lifeos/app/ui/settings/SettingsScreen.kt | 7 + .../app/ui/settings/SettingsViewModel.kt | 2 +- .../14.json | 2031 +++++++++++++++++ .../com/lifeos/core/database/LifeDatabase.kt | 13 +- .../core/database/brick/BrickEntities.kt | 135 ++ .../lifeos/core/database/di/DatabaseModule.kt | 4 + .../core/ui/navigation/LifeDestination.kt | 3 + feature/brick/build.gradle.kts | 18 + feature/brick/src/main/AndroidManifest.xml | 70 + .../com/lifeos/feature/brick/BrickScreen.kt | 501 ++++ .../lifeos/feature/brick/BrickViewModel.kt | 224 ++ .../lifeos/feature/brick/data/BrickPolicy.kt | 76 + .../feature/brick/data/BrickRepository.kt | 234 ++ .../feature/brick/data/BrickScheduler.kt | 111 + .../brick/engine/BrickAccessibilityService.kt | 133 ++ .../feature/brick/nfc/BrickNfcActivity.kt | 89 + .../feature/brick/ui/BlockWallActivity.kt | 109 + feature/brick/src/main/res/values/strings.xml | 4 + .../res/xml/brick_accessibility_config.xml | 13 + .../brick/src/main/res/xml/brick_nfc_tech.xml | 25 + .../feature/brick/data/BrickPolicyTest.kt | 115 + .../screentime/data/ScreenTimeCollector.kt | 9 +- settings.gradle.kts | 1 + 28 files changed, 3954 insertions(+), 13 deletions(-) create mode 100644 core/database/schemas/com.lifeos.core.database.LifeDatabase/14.json create mode 100644 core/database/src/main/kotlin/com/lifeos/core/database/brick/BrickEntities.kt create mode 100644 feature/brick/build.gradle.kts create mode 100644 feature/brick/src/main/AndroidManifest.xml create mode 100644 feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickScreen.kt create mode 100644 feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickViewModel.kt create mode 100644 feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickPolicy.kt create mode 100644 feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickRepository.kt create mode 100644 feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickScheduler.kt create mode 100644 feature/brick/src/main/kotlin/com/lifeos/feature/brick/engine/BrickAccessibilityService.kt create mode 100644 feature/brick/src/main/kotlin/com/lifeos/feature/brick/nfc/BrickNfcActivity.kt create mode 100644 feature/brick/src/main/kotlin/com/lifeos/feature/brick/ui/BlockWallActivity.kt create mode 100644 feature/brick/src/main/res/values/strings.xml create mode 100644 feature/brick/src/main/res/xml/brick_accessibility_config.xml create mode 100644 feature/brick/src/main/res/xml/brick_nfc_tech.xml create mode 100644 feature/brick/src/test/kotlin/com/lifeos/feature/brick/data/BrickPolicyTest.kt diff --git a/README.md b/README.md index 61b9272..7954c4b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ or on your own NAS (Ollama). No third-party cloud, ever. **Spec:** [`docs/PRODUCTION_PLAN.md`](docs/PRODUCTION_PLAN.md). Every module and rule traces back to a section (and often a community demand source) there. -## Status — v0.1.0-alpha.16 +## Status — v0.1.0-alpha.17 | Area | State | |---|---| @@ -43,6 +43,7 @@ and rule traces back to a section (and often a community demand source) there. | Focus timer: tap the ring for a custom HH:MM:SS time, plus an "Overlay" button that floats the countdown over any app (tap once for a close X that leaves it running); Overwhelm overlay now follows the theme | ✅ | | Screen Time: mirrors Android digital-wellbeing into LifeOS and keeps it forever (survives Samsung's ~monthly purge) — weekly bars + average, week scrolling, per-app breakdown, unlocks, JSON export | ✅ | | Screen Time · Plants (custom photos + care atlas) · News · Downloader on Home; NAS server apps redesigned as an app-store list; Jarvis answers from a live data snapshot (note bodies included) and a Developer Options "Jarvis Debugging" toggle exposes snapshot/output/tool-calls with a copy button | ✅ | +| Brick (§Module Brick): tap-to-block modes — pick blocked apps + optional per-app daily allowances, turn a mode on/off by NFC tag, time window or by hand, strict mode refuses early exits; blocked apps hit a full-screen wall via an accessibility blocker (the only route Android gives a sideloaded app). Blocking rules covered by unit tests | ✅ | | Deferred post-alpha: Glance home-screen widgets, HA WebSocket live state/zones, Vault unlock UI, first-run onboarding checklist (grants live in Settings → System access), FinTS bank sync | 🔜 | **Google-free by design:** no Google service is ever called at runtime (no Play Services, no Google recognizer, no Google Maps). Remaining Google-*authored* open-source, fully on-device libraries: AndroidX/Jetpack (unavoidable on Android), MediaPipe (Gemma inference), ML Kit on-device OCR/barcode (no network) — swap candidates documented in the plan. @@ -52,7 +53,7 @@ and rule traces back to a section (and often a community demand source) there. Grab `lifeos-v*.apk` from [Releases](../../releases), then: ``` -adb install -r -g lifeos-v0.1.0-alpha.16.apk +adb install -r -g lifeos-v0.1.0-alpha.17.apk ``` or copy to the phone and allow *Install unknown apps*. Android 13+ (minSdk 33). @@ -80,7 +81,8 @@ app/ Shell: theme, bottom bar, NavHost, Home grid + planner ca core/{model,common,designsystem,database,datastore,network,ai,service,vault,ui} feature/{chat,capture,notes,reminders,todo,calendar,messagecenter,dhl, imagereasoning,finance,email,nas,books,route,smarthome,planner, - clock,adhd,memex,agentic,evolution,downloader,plants,news,vault} + clock,adhd,memex,agentic,evolution,downloader,plants,news,vault, + screentime,brick} build-logic/ Convention plugins docs/PRODUCTION_PLAN.md ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e05da57..7ff31ac 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,8 +10,8 @@ android { defaultConfig { applicationId = "com.lifeos" - versionCode = 16 - versionName = "0.1.0-alpha.16" + versionCode = 17 + versionName = "0.1.0-alpha.17" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -86,6 +86,7 @@ dependencies { implementation(projects.feature.news) implementation(projects.feature.vault) implementation(projects.feature.screentime) + implementation(projects.feature.brick) implementation(projects.core.model) implementation(projects.core.network) diff --git a/app/src/main/kotlin/com/lifeos/app/MainActivity.kt b/app/src/main/kotlin/com/lifeos/app/MainActivity.kt index 0141fbb..3e350f2 100644 --- a/app/src/main/kotlin/com/lifeos/app/MainActivity.kt +++ b/app/src/main/kotlin/com/lifeos/app/MainActivity.kt @@ -12,11 +12,14 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch import com.lifeos.app.ui.LifeOsApp import com.lifeos.core.datastore.SettingsRepository import com.lifeos.core.designsystem.theme.LifeOsTheme import com.lifeos.core.designsystem.theme.PALETTE_DYNAMIC import com.lifeos.core.service.LifeOsForegroundService +import com.lifeos.feature.brick.data.BrickRepository import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -26,6 +29,9 @@ class MainActivity : FragmentActivity() { @Inject lateinit var settingsRepository: SettingsRepository + @Inject + lateinit var brickRepository: BrickRepository + /** Bumped each time the assistant gesture asks for quick capture. */ private val captureRequests = mutableStateOf(0) @@ -37,6 +43,8 @@ class MainActivity : FragmentActivity() { enableEdgeToEdge() LifeOsForegroundService.start(this) + // Reload any live Brick mode and re-arm its time windows (survives reboots). + lifecycleScope.launch { brickRepository.refresh() } requestNotificationsPermission() if (isCaptureIntent(intent)) captureRequests.value++ diff --git a/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt b/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt index 31c0af4..e5e37cf 100644 --- a/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt +++ b/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt @@ -50,6 +50,7 @@ import com.lifeos.feature.plants.PlantsRoute import com.lifeos.feature.news.NewsRoute import com.lifeos.feature.vault.VaultRoute import com.lifeos.feature.screentime.ScreenTimeRoute +import com.lifeos.feature.brick.BrickRoute /** * Single-activity app shell (§1.3): adaptive scaffold, short M3E bottom bar, @@ -191,6 +192,7 @@ fun LifeOsApp(captureRequests: Int = 0, navBarIds: List = emptyList()) { composable { NewsRoute() } composable { VaultRoute() } composable { ScreenTimeRoute() } + composable { BrickRoute() } } } diff --git a/app/src/main/kotlin/com/lifeos/app/ui/screen/HomeScreen.kt b/app/src/main/kotlin/com/lifeos/app/ui/screen/HomeScreen.kt index 4d63da8..780c5ac 100644 --- a/app/src/main/kotlin/com/lifeos/app/ui/screen/HomeScreen.kt +++ b/app/src/main/kotlin/com/lifeos/app/ui/screen/HomeScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.LocalFlorist import androidx.compose.material.icons.filled.Newspaper +import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.Timelapse import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.automirrored.filled.ViewList @@ -198,6 +199,12 @@ fun HomeScreen( icon = Icons.Filled.Newspaper, destination = LifeDestination.News, ), + AppGridItem( + label = "Brick", + description = "Tap-to-block modes for distracting apps", + icon = Icons.Filled.Shield, + destination = LifeDestination.Brick, + ), AppGridItem( label = "Screen Time", description = "Digital wellbeing, kept forever", @@ -227,15 +234,15 @@ fun HomeScreen( Text( "LifeOS", style = MaterialTheme.typography.headlineMedium, - // Hold for a full 5 seconds (not the ~0.5s system long-press) to - // reveal the hidden Vault — deliberate, hard to trigger by accident. + // Hold for 3 seconds (not the ~0.5s system long-press) to reveal + // the hidden Vault — deliberate, hard to trigger by accident. modifier = Modifier.pointerInput(Unit) { awaitEachGesture { awaitFirstDown() - val revealed = withTimeoutOrNull(5_000L) { + val revealed = withTimeoutOrNull(3_000L) { waitForUpOrCancellation() - false // released before 5s - } ?: true // 5s elapsed while still held + false // released before 3s + } ?: true // 3s elapsed while still held if (revealed) vaultRevealed = true } }, diff --git a/app/src/main/kotlin/com/lifeos/app/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/lifeos/app/ui/settings/SettingsScreen.kt index bfd8d20..2d5bb6b 100644 --- a/app/src/main/kotlin/com/lifeos/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/lifeos/app/ui/settings/SettingsScreen.kt @@ -266,6 +266,13 @@ fun SettingsRoute( } } + SystemSettingRow( + title = "App blocking (Brick)", + subtitle = "Enable \"LifeOS Brick\" so blocking modes can stop blocked apps from opening", + ) { + context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + } + SectionHeader(title = "Storage") SystemSettingRow( title = "Readable /LifeOS folder", diff --git a/app/src/main/kotlin/com/lifeos/app/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/lifeos/app/ui/settings/SettingsViewModel.kt index c9858d6..32e2c8e 100644 --- a/app/src/main/kotlin/com/lifeos/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/lifeos/app/ui/settings/SettingsViewModel.kt @@ -131,7 +131,7 @@ class SettingsViewModel @Inject constructor( val DEFAULT_HOME_ORDER = listOf( "Notes", "Logger", "Packages", "Finance", "Scan", "Planner", "Books", "Routes", "Smart home", "NAS", "Clock", "Focus", "Memex", "Macros", "Evolution", - "Downloader", "Plants", "News", "Screen Time", + "Downloader", "Plants", "News", "Brick", "Screen Time", ) } } diff --git a/core/database/schemas/com.lifeos.core.database.LifeDatabase/14.json b/core/database/schemas/com.lifeos.core.database.LifeDatabase/14.json new file mode 100644 index 0000000..cd80324 --- /dev/null +++ b/core/database/schemas/com.lifeos.core.database.LifeDatabase/14.json @@ -0,0 +1,2031 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "93faacf0f96209fbe4c3fe2234422f9a", + "entities": [ + { + "tableName": "vault_blobs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ref` TEXT NOT NULL, `algo` TEXT NOT NULL, `keyAlias` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `title` TEXT, `createdAt` INTEGER NOT NULL, `nasSynced` INTEGER NOT NULL, PRIMARY KEY(`ref`))", + "fields": [ + { + "fieldPath": "ref", + "columnName": "ref", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "algo", + "columnName": "algo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyAlias", + "columnName": "keyAlias", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nasSynced", + "columnName": "nasSynced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ref" + ] + } + }, + { + "tableName": "ai_conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ai_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conversationId` INTEGER NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `engine` TEXT, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`conversationId`) REFERENCES `ai_conversations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "engine", + "columnName": "engine", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ai_messages_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "ai_conversations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "conversationId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "captures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `text` TEXT, `blobVaultRef` TEXT, `routedTo` TEXT, `routedEntityId` INTEGER, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT" + }, + { + "fieldPath": "blobVaultRef", + "columnName": "blobVaultRef", + "affinity": "TEXT" + }, + { + "fieldPath": "routedTo", + "columnName": "routedTo", + "affinity": "TEXT" + }, + { + "fieldPath": "routedEntityId", + "columnName": "routedEntityId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "log_forms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `fieldsJson` TEXT NOT NULL, `color` INTEGER, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldsJson", + "columnName": "fieldsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "log_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `formId` INTEGER NOT NULL, `valuesJson` TEXT NOT NULL, `source` TEXT NOT NULL, `at` INTEGER NOT NULL, FOREIGN KEY(`formId`) REFERENCES `log_forms`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "formId", + "columnName": "formId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "valuesJson", + "columnName": "valuesJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "at", + "columnName": "at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_log_entries_formId", + "unique": false, + "columnNames": [ + "formId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_entries_formId` ON `${TABLE_NAME}` (`formId`)" + } + ], + "foreignKeys": [ + { + "table": "log_forms", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "formId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `done` INTEGER NOT NULL, `listId` INTEGER, `parentId` INTEGER, `dueAt` INTEGER, `sourceModule` TEXT, `sourceEntityId` INTEGER, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "done", + "columnName": "done", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "listId", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER" + }, + { + "fieldPath": "dueAt", + "columnName": "dueAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "sourceModule", + "columnName": "sourceModule", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceEntityId", + "columnName": "sourceEntityId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `path` TEXT NOT NULL, `title` TEXT NOT NULL, `bodyVaultRef` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bodyVaultRef", + "columnName": "bodyVaultRef", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_notes_path", + "unique": true, + "columnNames": [ + "path" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_notes_path` ON `${TABLE_NAME}` (`path`)" + } + ] + }, + { + "tableName": "note_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `fromNoteId` INTEGER NOT NULL, `toTitle` TEXT NOT NULL, FOREIGN KEY(`fromNoteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fromNoteId", + "columnName": "fromNoteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toTitle", + "columnName": "toTitle", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_note_links_fromNoteId", + "unique": false, + "columnNames": [ + "fromNoteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_links_fromNoteId` ON `${TABLE_NAME}` (`fromNoteId`)" + }, + { + "name": "index_note_links_toTitle", + "unique": false, + "columnNames": [ + "toTitle" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_links_toTitle` ON `${TABLE_NAME}` (`toTitle`)" + } + ], + "foreignKeys": [ + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "fromNoteId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "note_embeddings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `noteId` INTEGER NOT NULL, `chunkIndex` INTEGER NOT NULL, `chunkText` TEXT NOT NULL, `vector` BLOB NOT NULL, FOREIGN KEY(`noteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteId", + "columnName": "noteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chunkIndex", + "columnName": "chunkIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chunkText", + "columnName": "chunkText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vector", + "columnName": "vector", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_note_embeddings_noteId", + "unique": false, + "columnNames": [ + "noteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_embeddings_noteId` ON `${TABLE_NAME}` (`noteId`)" + } + ], + "foreignKeys": [ + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "noteId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "reminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `notes` TEXT, `at` INTEGER NOT NULL, `recurrence` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `firedAt` INTEGER, `sourceModule` TEXT, `sourceEntityId` INTEGER, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT" + }, + { + "fieldPath": "at", + "columnName": "at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recurrence", + "columnName": "recurrence", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firedAt", + "columnName": "firedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "sourceModule", + "columnName": "sourceModule", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceEntityId", + "columnName": "sourceEntityId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "task_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "calendar_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `location` TEXT, `notes` TEXT, `startsAt` INTEGER NOT NULL, `endsAt` INTEGER NOT NULL, `allDay` INTEGER NOT NULL, `reminderId` INTEGER, `systemEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT" + }, + { + "fieldPath": "startsAt", + "columnName": "startsAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endsAt", + "columnName": "endsAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allDay", + "columnName": "allDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderId", + "columnName": "reminderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "systemEventId", + "columnName": "systemEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "unified_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appPackage` TEXT NOT NULL, `appLabel` TEXT NOT NULL, `title` TEXT, `text` TEXT, `notificationKey` TEXT NOT NULL, `postedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appPackage", + "columnName": "appPackage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appLabel", + "columnName": "appLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT" + }, + { + "fieldPath": "notificationKey", + "columnName": "notificationKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postedAt", + "columnName": "postedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_unified_messages_appPackage", + "unique": false, + "columnNames": [ + "appPackage" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_unified_messages_appPackage` ON `${TABLE_NAME}` (`appPackage`)" + }, + { + "name": "index_unified_messages_notificationKey_postedAt", + "unique": true, + "columnNames": [ + "notificationKey", + "postedAt" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_unified_messages_notificationKey_postedAt` ON `${TABLE_NAME}` (`notificationKey`, `postedAt`)" + } + ] + }, + { + "tableName": "packages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trackingNumber` TEXT NOT NULL, `label` TEXT, `status` TEXT NOT NULL, `statusDescription` TEXT, `estimatedDeliveryAt` INTEGER, `reminderId` INTEGER, `lastRefreshedAt` INTEGER, `sourceModule` TEXT, `sourceEntityId` INTEGER, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackingNumber", + "columnName": "trackingNumber", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusDescription", + "columnName": "statusDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "estimatedDeliveryAt", + "columnName": "estimatedDeliveryAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "reminderId", + "columnName": "reminderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastRefreshedAt", + "columnName": "lastRefreshedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "sourceModule", + "columnName": "sourceModule", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceEntityId", + "columnName": "sourceEntityId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_packages_trackingNumber", + "unique": true, + "columnNames": [ + "trackingNumber" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_packages_trackingNumber` ON `${TABLE_NAME}` (`trackingNumber`)" + } + ] + }, + { + "tableName": "tracking_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `packageId` INTEGER NOT NULL, `status` TEXT NOT NULL, `description` TEXT, `location` TEXT, `at` INTEGER NOT NULL, FOREIGN KEY(`packageId`) REFERENCES `packages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageId", + "columnName": "packageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "at", + "columnName": "at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tracking_events_packageId", + "unique": false, + "columnNames": [ + "packageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracking_events_packageId` ON `${TABLE_NAME}` (`packageId`)" + } + ], + "foreignKeys": [ + { + "table": "packages", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "packageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "scanned_documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `imagePath` TEXT, `ocrText` TEXT NOT NULL, `extractedJson` TEXT, `linkedModule` TEXT, `linkedEntityId` INTEGER, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imagePath", + "columnName": "imagePath", + "affinity": "TEXT" + }, + { + "fieldPath": "ocrText", + "columnName": "ocrText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extractedJson", + "columnName": "extractedJson", + "affinity": "TEXT" + }, + { + "fieldPath": "linkedModule", + "columnName": "linkedModule", + "affinity": "TEXT" + }, + { + "fieldPath": "linkedEntityId", + "columnName": "linkedEntityId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `merchant` TEXT NOT NULL, `amountCents` INTEGER NOT NULL, `categoryId` INTEGER, `at` INTEGER NOT NULL, `source` TEXT NOT NULL, `sourceDocId` INTEGER, `notes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "merchant", + "columnName": "merchant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountCents", + "columnName": "amountCents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "INTEGER" + }, + { + "fieldPath": "at", + "columnName": "at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceDocId", + "columnName": "sourceDocId", + "affinity": "INTEGER" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_transactions_categoryId", + "unique": false, + "columnNames": [ + "categoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_categoryId` ON `${TABLE_NAME}` (`categoryId`)" + }, + { + "name": "index_transactions_at", + "unique": false, + "columnNames": [ + "at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_at` ON `${TABLE_NAME}` (`at`)" + } + ] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_categories_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_categories_name` ON `${TABLE_NAME}` (`name`)" + } + ] + }, + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `merchant` TEXT NOT NULL, `amountCents` INTEGER NOT NULL, `cadence` TEXT NOT NULL, `lastChargedAt` INTEGER NOT NULL, `status` TEXT NOT NULL, `cancelUrl` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "merchant", + "columnName": "merchant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountCents", + "columnName": "amountCents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cadence", + "columnName": "cadence", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastChargedAt", + "columnName": "lastChargedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cancelUrl", + "columnName": "cancelUrl", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_subscriptions_merchant", + "unique": true, + "columnNames": [ + "merchant" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_merchant` ON `${TABLE_NAME}` (`merchant`)" + } + ] + }, + { + "tableName": "warranties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productName` TEXT NOT NULL, `purchaseTxId` INTEGER, `purchasedAt` INTEGER NOT NULL, `warrantyMonths` INTEGER NOT NULL, `reminderId` INTEGER, `docId` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productName", + "columnName": "productName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purchaseTxId", + "columnName": "purchaseTxId", + "affinity": "INTEGER" + }, + { + "fieldPath": "purchasedAt", + "columnName": "purchasedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "warrantyMonths", + "columnName": "warrantyMonths", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderId", + "columnName": "reminderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "docId", + "columnName": "docId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "email_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `messageUid` TEXT NOT NULL, `from` TEXT NOT NULL, `subject` TEXT NOT NULL, `preview` TEXT NOT NULL, `receivedAt` INTEGER NOT NULL, `hasInvoiceSignal` INTEGER NOT NULL, `hasInviteSignal` INTEGER NOT NULL, `hasSubscriptionSignal` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageUid", + "columnName": "messageUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "from", + "columnName": "from", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "preview", + "columnName": "preview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receivedAt", + "columnName": "receivedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasInvoiceSignal", + "columnName": "hasInvoiceSignal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasInviteSignal", + "columnName": "hasInviteSignal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSubscriptionSignal", + "columnName": "hasSubscriptionSignal", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_email_messages_messageUid", + "unique": true, + "columnNames": [ + "messageUid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_email_messages_messageUid` ON `${TABLE_NAME}` (`messageUid`)" + } + ] + }, + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT NOT NULL, `isbn` TEXT, `status` TEXT NOT NULL, `ratingHalfStars` INTEGER, `notes` TEXT, `addedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isbn", + "columnName": "isbn", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ratingHalfStars", + "columnName": "ratingHalfStars", + "affinity": "INTEGER" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT" + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "saved_places", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `query` TEXT NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "archive_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `source` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, `body` TEXT NOT NULL, `capturedAt` INTEGER NOT NULL, `annotated` INTEGER NOT NULL, `annotation` TEXT NOT NULL, `expiresAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "capturedAt", + "columnName": "capturedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "annotated", + "columnName": "annotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "annotation", + "columnName": "annotation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "macros", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `nlPrompt` TEXT NOT NULL, `stepsJson` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastRunAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nlPrompt", + "columnName": "nlPrompt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stepsJson", + "columnName": "stepsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastRunAt", + "columnName": "lastRunAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "focus_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `minutes` INTEGER NOT NULL, `startedAt` INTEGER NOT NULL, `completed` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutes", + "columnName": "minutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completed", + "columnName": "completed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "interaction_logs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `engine` TEXT NOT NULL, `kind` TEXT NOT NULL, `accepted` INTEGER, `at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "engine", + "columnName": "engine", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accepted", + "columnName": "accepted", + "affinity": "INTEGER" + }, + { + "fieldPath": "at", + "columnName": "at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceUrl` TEXT NOT NULL, `mediaUrl` TEXT NOT NULL, `title` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `status` TEXT NOT NULL, `progressPercent` INTEGER NOT NULL, `sizeBytes` INTEGER NOT NULL, `savedUri` TEXT, `error` TEXT, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaUrl", + "columnName": "mediaUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progressPercent", + "columnName": "progressPercent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedUri", + "columnName": "savedUri", + "affinity": "TEXT" + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "my_plants", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `speciesId` TEXT NOT NULL, `waterEveryDays` INTEGER NOT NULL, `lastWateredAt` INTEGER, `reminderId` INTEGER, `createdAt` INTEGER NOT NULL, `photoPath` TEXT, `notes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "speciesId", + "columnName": "speciesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "waterEveryDays", + "columnName": "waterEveryDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastWateredAt", + "columnName": "lastWateredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "reminderId", + "columnName": "reminderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "photoPath", + "columnName": "photoPath", + "affinity": "TEXT" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "screen_time_days", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, `totalForegroundMs` INTEGER NOT NULL, `unlocks` INTEGER NOT NULL, `notifications` INTEGER NOT NULL, `capturedAt` INTEGER NOT NULL, PRIMARY KEY(`date`))", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalForegroundMs", + "columnName": "totalForegroundMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unlocks", + "columnName": "unlocks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notifications", + "columnName": "notifications", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "capturedAt", + "columnName": "capturedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "date" + ] + } + }, + { + "tableName": "screen_time_apps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, `packageName` TEXT NOT NULL, `label` TEXT NOT NULL, `foregroundMs` INTEGER NOT NULL, PRIMARY KEY(`date`, `packageName`))", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "foregroundMs", + "columnName": "foregroundMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "date", + "packageName" + ] + } + }, + { + "tableName": "brick_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `blockedPackages` TEXT NOT NULL, `activator` TEXT NOT NULL, `deactivator` TEXT NOT NULL, `nfcTagId` TEXT, `startMinuteOfDay` INTEGER, `endMinuteOfDay` INTEGER, `strict` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedPackages", + "columnName": "blockedPackages", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activator", + "columnName": "activator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deactivator", + "columnName": "deactivator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nfcTagId", + "columnName": "nfcTagId", + "affinity": "TEXT" + }, + { + "fieldPath": "startMinuteOfDay", + "columnName": "startMinuteOfDay", + "affinity": "INTEGER" + }, + { + "fieldPath": "endMinuteOfDay", + "columnName": "endMinuteOfDay", + "affinity": "INTEGER" + }, + { + "fieldPath": "strict", + "columnName": "strict", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "brick_app_limits", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `dailyMinutes` INTEGER NOT NULL, PRIMARY KEY(`profileId`, `packageName`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dailyMinutes", + "columnName": "dailyMinutes", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId", + "packageName" + ] + } + }, + { + "tableName": "brick_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `profileId` INTEGER NOT NULL, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER, `startedBy` TEXT NOT NULL, `blockedAttempts` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "startedBy", + "columnName": "startedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAttempts", + "columnName": "blockedAttempts", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "brick_usage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, `packageName` TEXT NOT NULL, `secondsUsed` INTEGER NOT NULL, PRIMARY KEY(`date`, `packageName`))", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondsUsed", + "columnName": "secondsUsed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "date", + "packageName" + ] + } + } + ], + "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, '93faacf0f96209fbe4c3fe2234422f9a')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/kotlin/com/lifeos/core/database/LifeDatabase.kt b/core/database/src/main/kotlin/com/lifeos/core/database/LifeDatabase.kt index 9deb512..73c3962 100644 --- a/core/database/src/main/kotlin/com/lifeos/core/database/LifeDatabase.kt +++ b/core/database/src/main/kotlin/com/lifeos/core/database/LifeDatabase.kt @@ -53,6 +53,11 @@ import com.lifeos.core.database.downloads.DownloadDao import com.lifeos.core.database.downloads.DownloadEntity import com.lifeos.core.database.plants.MyPlantEntity import com.lifeos.core.database.plants.PlantDao +import com.lifeos.core.database.brick.BrickAppLimitEntity +import com.lifeos.core.database.brick.BrickDao +import com.lifeos.core.database.brick.BrickProfileEntity +import com.lifeos.core.database.brick.BrickSessionEntity +import com.lifeos.core.database.brick.BrickUsageEntity import com.lifeos.core.database.screentime.AppUsageEntity import com.lifeos.core.database.screentime.ScreenTimeDao import com.lifeos.core.database.screentime.ScreenTimeDayEntity @@ -95,8 +100,12 @@ import com.lifeos.core.database.screentime.ScreenTimeDayEntity MyPlantEntity::class, ScreenTimeDayEntity::class, AppUsageEntity::class, + BrickProfileEntity::class, + BrickAppLimitEntity::class, + BrickSessionEntity::class, + BrickUsageEntity::class, ], - version = 13, + version = 14, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -111,6 +120,7 @@ import com.lifeos.core.database.screentime.ScreenTimeDayEntity AutoMigration(from = 10, to = 11), AutoMigration(from = 11, to = 12), AutoMigration(from = 12, to = 13), + AutoMigration(from = 13, to = 14), ], ) abstract class LifeDatabase : RoomDatabase() { @@ -135,6 +145,7 @@ abstract class LifeDatabase : RoomDatabase() { abstract fun downloadDao(): DownloadDao abstract fun plantDao(): PlantDao abstract fun screenTimeDao(): ScreenTimeDao + abstract fun brickDao(): BrickDao companion object { const val NAME = "life-os.db" diff --git a/core/database/src/main/kotlin/com/lifeos/core/database/brick/BrickEntities.kt b/core/database/src/main/kotlin/com/lifeos/core/database/brick/BrickEntities.kt new file mode 100644 index 0000000..fa7e738 --- /dev/null +++ b/core/database/src/main/kotlin/com/lifeos/core/database/brick/BrickEntities.kt @@ -0,0 +1,135 @@ +package com.lifeos.core.database.brick + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +/** + * A blocking mode (§Module Brick). Holds which apps are blocked, how the mode + * turns on, and what it takes to turn off again. + * + * [activator]/[deactivator]: NFC, TIME, MANUAL. + * [nfcTagId] is the paired tag's hardware id (hex) — the tap that flips the mode. + * [startMinuteOfDay]/[endMinuteOfDay] drive TIME activation (minutes since midnight). + * [strict] blocks the in-app "stop" button, so only the real condition ends it. + */ +@Entity(tableName = "brick_profiles") +data class BrickProfileEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + /** Package names, newline-separated. */ + val blockedPackages: String, + val activator: String = "MANUAL", + val deactivator: String = "MANUAL", + val nfcTagId: String? = null, + val startMinuteOfDay: Int? = null, + val endMinuteOfDay: Int? = null, + val strict: Boolean = false, + val createdAt: Long, +) + +/** Optional per-app daily allowance inside a profile (minutes; 0 = fully blocked). */ +@Entity(tableName = "brick_app_limits", primaryKeys = ["profileId", "packageName"]) +data class BrickAppLimitEntity( + val profileId: Long, + val packageName: String, + val dailyMinutes: Int, +) + +/** One run of a profile. [endedAt] null while the mode is live. */ +@Entity(tableName = "brick_sessions") +data class BrickSessionEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val profileId: Long, + val startedAt: Long, + val endedAt: Long? = null, + /** How it started: NFC / TIME / MANUAL. */ + val startedBy: String, + /** Attempts to open a blocked app while the mode was live. */ + val blockedAttempts: Int = 0, +) + +/** Foreground minutes per app per day — powers the daily-allowance limits. */ +@Entity(tableName = "brick_usage", primaryKeys = ["date", "packageName"]) +data class BrickUsageEntity( + /** yyyy-MM-dd, local zone. */ + val date: String, + val packageName: String, + val secondsUsed: Long, +) + +@Dao +interface BrickDao { + + // ---- profiles --------------------------------------------------------- + @Insert + suspend fun insertProfile(profile: BrickProfileEntity): Long + + @Update + suspend fun updateProfile(profile: BrickProfileEntity) + + @Query("SELECT * FROM brick_profiles ORDER BY createdAt") + fun observeProfiles(): Flow> + + @Query("SELECT * FROM brick_profiles") + suspend fun allProfiles(): List + + @Query("SELECT * FROM brick_profiles WHERE id = :id") + suspend fun profile(id: Long): BrickProfileEntity? + + @Query("SELECT * FROM brick_profiles WHERE nfcTagId = :tagId LIMIT 1") + suspend fun profileByTag(tagId: String): BrickProfileEntity? + + @Query("DELETE FROM brick_profiles WHERE id = :id") + suspend fun deleteProfile(id: Long) + + // ---- limits ---------------------------------------------------------- + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertLimit(limit: BrickAppLimitEntity) + + @Query("SELECT * FROM brick_app_limits WHERE profileId = :profileId") + suspend fun limitsFor(profileId: Long): List + + @Query("SELECT * FROM brick_app_limits WHERE profileId = :profileId") + fun observeLimits(profileId: Long): Flow> + + @Query("DELETE FROM brick_app_limits WHERE profileId = :profileId AND packageName = :packageName") + suspend fun deleteLimit(profileId: Long, packageName: String) + + // ---- sessions -------------------------------------------------------- + @Insert + suspend fun insertSession(session: BrickSessionEntity): Long + + @Update + suspend fun updateSession(session: BrickSessionEntity) + + @Query("SELECT * FROM brick_sessions WHERE endedAt IS NULL ORDER BY startedAt DESC LIMIT 1") + suspend fun activeSession(): BrickSessionEntity? + + @Query("SELECT * FROM brick_sessions WHERE endedAt IS NULL ORDER BY startedAt DESC LIMIT 1") + fun observeActiveSession(): Flow + + @Query("SELECT * FROM brick_sessions ORDER BY startedAt DESC LIMIT 50") + fun observeRecentSessions(): Flow> + + @Query("UPDATE brick_sessions SET blockedAttempts = blockedAttempts + 1 WHERE id = :sessionId") + suspend fun incrementAttempts(sessionId: Long) + + @Query("UPDATE brick_sessions SET endedAt = :endedAt WHERE endedAt IS NULL") + suspend fun endAllSessions(endedAt: Long) + + // ---- usage ----------------------------------------------------------- + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertUsage(usage: BrickUsageEntity) + + @Query("SELECT * FROM brick_usage WHERE date = :date AND packageName = :packageName") + suspend fun usage(date: String, packageName: String): BrickUsageEntity? + + @Query("SELECT * FROM brick_usage WHERE date = :date") + fun observeUsage(date: String): Flow> +} diff --git a/core/database/src/main/kotlin/com/lifeos/core/database/di/DatabaseModule.kt b/core/database/src/main/kotlin/com/lifeos/core/database/di/DatabaseModule.kt index 545c8b0..9220faa 100644 --- a/core/database/src/main/kotlin/com/lifeos/core/database/di/DatabaseModule.kt +++ b/core/database/src/main/kotlin/com/lifeos/core/database/di/DatabaseModule.kt @@ -23,6 +23,7 @@ import com.lifeos.core.database.adhd.FocusDao import com.lifeos.core.database.evolution.EvolutionDao import com.lifeos.core.database.downloads.DownloadDao import com.lifeos.core.database.plants.PlantDao +import com.lifeos.core.database.brick.BrickDao import com.lifeos.core.database.screentime.ScreenTimeDao import dagger.Module import dagger.Provides @@ -103,4 +104,7 @@ internal object DatabaseModule { @Provides fun provideScreenTimeDao(database: LifeDatabase): ScreenTimeDao = database.screenTimeDao() + + @Provides + fun provideBrickDao(database: LifeDatabase): BrickDao = database.brickDao() } diff --git a/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/LifeDestination.kt b/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/LifeDestination.kt index a9d895e..5be23e2 100644 --- a/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/LifeDestination.kt +++ b/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/LifeDestination.kt @@ -88,6 +88,9 @@ sealed interface LifeDestination { @Serializable data object ScreenTime : LifeDestination + + @Serializable + data object Brick : LifeDestination } const val DEEP_LINK_SCHEME = "lifeos" diff --git a/feature/brick/build.gradle.kts b/feature/brick/build.gradle.kts new file mode 100644 index 0000000..04373de --- /dev/null +++ b/feature/brick/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + alias(libs.plugins.lifeos.android.library) + alias(libs.plugins.lifeos.android.compose) + alias(libs.plugins.lifeos.hilt) +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.designsystem) + implementation(projects.core.database) + implementation(projects.core.datastore) + implementation(projects.core.ui) + + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.compose.material.icons.extended) + testImplementation(libs.junit) +} diff --git a/feature/brick/src/main/AndroidManifest.xml b/feature/brick/src/main/AndroidManifest.xml new file mode 100644 index 0000000..406683f --- /dev/null +++ b/feature/brick/src/main/AndroidManifest.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickScreen.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickScreen.kt new file mode 100644 index 0000000..7a20333 --- /dev/null +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickScreen.kt @@ -0,0 +1,501 @@ +package com.lifeos.feature.brick + +import android.app.Activity +import android.nfc.NfcAdapter +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.Edit +import androidx.compose.material.icons.filled.Nfc +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.platform.LocalLifecycleOwner +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.lifeos.core.database.brick.BrickProfileEntity +import com.lifeos.core.designsystem.component.EmptyState +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Brick (§Module Brick): tap-to-block modes. Pick the apps a mode blocks, how + * it turns on (NFC tag, time window, or by hand) and what it takes to end it. + * While a mode runs, opening a blocked app lands on a full-screen wall. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BrickRoute(viewModel: BrickViewModel = hiltViewModel()) { + val profiles by viewModel.profiles.collectAsState() + val active by viewModel.active.collectAsState() + val serviceEnabled by viewModel.serviceEnabled.collectAsState() + val message by viewModel.message.collectAsState() + val draft by viewModel.draft.collectAsState() + val pairing by viewModel.pairingTag.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + // Re-check the accessibility grant whenever we come back into view. + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) viewModel.refresh() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + LaunchedEffect(message) { + message?.let { + snackbarHostState.showSnackbar(it) + viewModel.dismissMessage() + } + } + + // Foreground NFC reader while pairing a tag in the editor. + NfcPairingEffect(enabled = pairing, onTag = viewModel::onTagPaired) + + if (draft != null) { + ProfileEditor(viewModel = viewModel, snackbarHostState = snackbarHostState) + return + } + + Scaffold( + topBar = { TopAppBar(title = { Text("Brick") }) }, + floatingActionButton = { + FloatingActionButton(onClick = { viewModel.newProfile() }) { + Icon(Icons.Filled.Add, contentDescription = "New mode") + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (!serviceEnabled) { + item { + Card { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Blocking needs one grant", style = MaterialTheme.typography.titleMedium) + Text( + "Enable \"LifeOS Brick\" in accessibility settings. It only reads which app is in " + + "front — never screen content — and that is how Android lets an app block others.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = { viewModel.openAccessibilitySettings() }) { + Text("Open accessibility settings") + } + } + } + } + } + + active?.let { mode -> + item { + Card { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Blocking now", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Text(mode.profile.name, style = MaterialTheme.typography.headlineSmall) + Text( + "${mode.blockedPackages.size} app(s) blocked · since ${TIME.format(Date(mode.session.startedAt))} " + + "· ${mode.session.blockedAttempts} attempt(s) stopped", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + when (mode.profile.deactivator) { + "NFC" -> "Ends when you scan the paired tag" + "TIME" -> "Ends at ${formatMinute(mode.profile.endMinuteOfDay)}" + else -> if (mode.profile.strict) "Strict — cannot be ended early" else "Can be ended here" + }, + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedButton(onClick = { viewModel.stopNow() }) { Text("End mode") } + } + } + } + } + + if (profiles.isEmpty()) { + item { + EmptyState( + title = "No modes yet", + description = "Create a mode, pick the apps it blocks, and pair an NFC tag you keep far away — " + + "then a tap is the only way in or out.", + ) + } + } else { + item { Text("Modes", style = MaterialTheme.typography.titleMedium) } + items(profiles, key = { it.id }) { profile -> + ProfileCard( + profile = profile, + isActive = active?.profile?.id == profile.id, + onStart = { viewModel.startNow(profile) }, + onEdit = { viewModel.editProfile(profile) }, + onDelete = { viewModel.deleteProfile(profile.id) }, + ) + } + } + } + } +} + +@Composable +private fun ProfileCard( + profile: BrickProfileEntity, + isActive: Boolean, + onStart: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + Card { + Row(modifier = Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(profile.name, style = MaterialTheme.typography.titleMedium) + Text( + buildString { + append("${profile.blockedPackages.lines().count { it.isNotBlank() }} app(s)") + append(" · on: ${activatorLabel(profile.activator, profile.startMinuteOfDay)}") + append(" · off: ${activatorLabel(profile.deactivator, profile.endMinuteOfDay)}") + if (profile.strict) append(" · strict") + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (!isActive) { + IconButton(onClick = onStart) { Icon(Icons.Filled.PlayArrow, contentDescription = "Start now") } + } + IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, contentDescription = "Edit") } + IconButton(onClick = onDelete, enabled = !isActive) { + Icon(Icons.Filled.Delete, contentDescription = "Delete") + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProfileEditor(viewModel: BrickViewModel, snackbarHostState: SnackbarHostState) { + val draft by viewModel.draft.collectAsState() + val apps by viewModel.apps.collectAsState() + val pairing by viewModel.pairingTag.collectAsState() + val current = draft ?: return + var query by remember { mutableStateOf("") } + var showAllApps by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (current.id == 0L) "New mode" else "Edit mode") }, + navigationIcon = { + IconButton(onClick = { viewModel.closeEditor() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { TextButton(onClick = { viewModel.saveDraft() }) { Text("Save") } }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + OutlinedTextField( + value = current.name, + onValueChange = { value -> viewModel.updateDraft { it.copy(name = value) } }, + label = { Text("Mode name, e.g. No socials") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + + item { Text("Turns on with", style = MaterialTheme.typography.titleSmall) } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("MANUAL" to "By hand", "NFC" to "NFC tag", "TIME" to "Time").forEach { (value, label) -> + FilterChip( + selected = current.activator == value, + onClick = { viewModel.updateDraft { it.copy(activator = value) } }, + label = { Text(label) }, + ) + } + } + } + item { Text("Turns off with", style = MaterialTheme.typography.titleSmall) } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("MANUAL" to "By hand", "NFC" to "NFC tag", "TIME" to "Time").forEach { (value, label) -> + FilterChip( + selected = current.deactivator == value, + onClick = { viewModel.updateDraft { it.copy(deactivator = value) } }, + label = { Text(label) }, + ) + } + } + } + + if (current.activator == "NFC" || current.deactivator == "NFC") { + item { + Card { + Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Nfc, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text( + " " + (current.nfcTagId?.let { "Tag paired: $it" } ?: "No tag paired"), + style = MaterialTheme.typography.bodyMedium, + ) + } + Text( + if (pairing) "Hold the tag against the back of the phone…" + else "Any NFC tag or card works — one tap flips this mode on and off.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedButton(onClick = { viewModel.startTagPairing() }, enabled = !pairing) { + Text(if (current.nfcTagId == null) "Pair tag" else "Pair a different tag") + } + } + } + } + } + + if (current.activator == "TIME") { + item { + MinuteField( + label = "Starts at", + minuteOfDay = current.startMinuteOfDay, + onChange = { value -> viewModel.updateDraft { it.copy(startMinuteOfDay = value) } }, + ) + } + } + if (current.deactivator == "TIME") { + item { + MinuteField( + label = "Ends at", + minuteOfDay = current.endMinuteOfDay, + onChange = { value -> viewModel.updateDraft { it.copy(endMinuteOfDay = value) } }, + ) + } + } + + item { + Surface(modifier = Modifier.fillMaxWidth()) { + ListItem( + headlineContent = { Text("Strict mode") }, + supportingContent = { Text("Hides the in-app end button — only the real condition unlocks it") }, + trailingContent = { + Switch( + checked = current.strict, + onCheckedChange = { value -> viewModel.updateDraft { it.copy(strict = value) } }, + ) + }, + ) + } + } + + item { HorizontalDivider() } + item { + Text( + "Blocked apps (${current.blocked.size})", + style = MaterialTheme.typography.titleSmall, + ) + } + item { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + label = { Text("Search apps") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + item { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = showAllApps, onCheckedChange = { showAllApps = it }) + Text("Include system apps", style = MaterialTheme.typography.bodySmall) + } + } + + val shown = apps + .filter { showAllApps || !it.system } + .filter { query.isBlank() || it.label.contains(query, ignoreCase = true) } + items(shown, key = { it.packageName }) { app -> + val checked = app.packageName in current.blocked + val limit = current.limits[app.packageName] + Column { + Surface( + onClick = { + viewModel.updateDraft { draftState -> + val blocked = if (checked) draftState.blocked - app.packageName + else draftState.blocked + app.packageName + draftState.copy( + blocked = blocked, + limits = if (checked) draftState.limits - app.packageName else draftState.limits, + ) + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + ListItem( + headlineContent = { Text(app.label, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + supportingContent = { + Text( + if (limit != null) "Allowance: $limit min/day" else app.packageName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + leadingContent = { Checkbox(checked = checked, onCheckedChange = null) }, + ) + } + if (checked) { + Row( + modifier = Modifier.padding(start = 56.dp, bottom = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Limit:", style = MaterialTheme.typography.bodySmall) + listOf(null, 15, 30, 60).forEach { minutes -> + FilterChip( + selected = limit == minutes, + onClick = { + viewModel.updateDraft { draftState -> + draftState.copy( + limits = if (minutes == null) { + draftState.limits - app.packageName + } else { + draftState.limits + (app.packageName to minutes) + }, + ) + } + }, + label = { Text(minutes?.let { "${it}m" } ?: "Block") }, + ) + } + } + } + } + } + } + } +} + +@Composable +private fun MinuteField(label: String, minuteOfDay: Int?, onChange: (Int?) -> Unit) { + var hours by remember(minuteOfDay) { mutableStateOf(minuteOfDay?.let { (it / 60).toString() } ?: "") } + var minutes by remember(minuteOfDay) { mutableStateOf(minuteOfDay?.let { "%02d".format(it % 60) } ?: "") } + + fun push() { + val h = hours.toIntOrNull() + val m = minutes.toIntOrNull() ?: 0 + onChange(if (h == null) null else (h.coerceIn(0, 23) * 60 + m.coerceIn(0, 59))) + } + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(label, modifier = Modifier.padding(end = 4.dp)) + OutlinedTextField( + value = hours, + onValueChange = { hours = it.filter(Char::isDigit).take(2); push() }, + label = { Text("HH") }, + singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = androidx.compose.ui.text.input.KeyboardType.Number, + ), + modifier = Modifier.width(84.dp), + ) + OutlinedTextField( + value = minutes, + onValueChange = { minutes = it.filter(Char::isDigit).take(2); push() }, + label = { Text("MM") }, + singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = androidx.compose.ui.text.input.KeyboardType.Number, + ), + modifier = Modifier.width(84.dp), + ) + } +} + +/** Reader-mode NFC while the editor is pairing a tag. */ +@Composable +private fun NfcPairingEffect(enabled: Boolean, onTag: (String) -> Unit) { + val context = LocalContext.current + DisposableEffect(enabled) { + val activity = context as? Activity + val adapter = NfcAdapter.getDefaultAdapter(context) + if (!enabled || activity == null || adapter == null) return@DisposableEffect onDispose {} + val callback = NfcAdapter.ReaderCallback { tag -> + tag.id?.joinToString("") { "%02X".format(it) }?.takeIf { it.isNotEmpty() }?.let(onTag) + } + adapter.enableReaderMode( + activity, + callback, + NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or + NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V or + NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, + null, + ) + onDispose { runCatching { adapter.disableReaderMode(activity) } } + } +} + +private fun activatorLabel(kind: String, minuteOfDay: Int?): String = when (kind) { + "NFC" -> "tag" + "TIME" -> formatMinute(minuteOfDay) + else -> "hand" +} + +private fun formatMinute(minuteOfDay: Int?): String = + minuteOfDay?.let { "%02d:%02d".format(it / 60, it % 60) } ?: "--:--" + +private val TIME = SimpleDateFormat("HH:mm", Locale.getDefault()) diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickViewModel.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickViewModel.kt new file mode 100644 index 0000000..64aac57 --- /dev/null +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickViewModel.kt @@ -0,0 +1,224 @@ +package com.lifeos.feature.brick + +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lifeos.core.database.brick.BrickProfileEntity +import com.lifeos.core.database.brick.BrickSessionEntity +import com.lifeos.feature.brick.data.BrickRepository +import com.lifeos.feature.brick.engine.BrickAccessibilityService +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** One installed, launchable app the user can block. */ +data class InstalledApp(val packageName: String, val label: String, val system: Boolean) + +/** Draft state of the profile editor. */ +data class ProfileDraft( + val id: Long = 0, + val name: String = "", + val blocked: Set = emptySet(), + val limits: Map = emptyMap(), + val activator: String = "MANUAL", + val deactivator: String = "MANUAL", + val nfcTagId: String? = null, + val startMinuteOfDay: Int? = null, + val endMinuteOfDay: Int? = null, + val strict: Boolean = false, +) + +@HiltViewModel +class BrickViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val brickRepository: BrickRepository, +) : ViewModel() { + + val profiles = brickRepository.observeProfiles() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val sessions = brickRepository.observeSessions() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val active = brickRepository.active + + private val _serviceEnabled = MutableStateFlow(false) + val serviceEnabled = _serviceEnabled.asStateFlow() + + private val _apps = MutableStateFlow>(emptyList()) + val apps = _apps.asStateFlow() + + private val _draft = MutableStateFlow(null) + val draft = _draft.asStateFlow() + + private val _message = MutableStateFlow(null) + val message = _message.asStateFlow() + + /** Tag id captured by the editor's "pair tag" flow, if any. */ + private val _pairingTag = MutableStateFlow(false) + val pairingTag = _pairingTag.asStateFlow() + + init { + refresh() + loadApps() + } + + fun refresh() { + _serviceEnabled.value = BrickAccessibilityService.isConnected || + BrickAccessibilityService.isEnabledInSettings(context) + viewModelScope.launch { brickRepository.refresh() } + } + + private fun loadApps() { + viewModelScope.launch { + _apps.value = withContext(Dispatchers.IO) { + val packageManager = context.packageManager + val launchable = packageManager + .queryIntentActivities(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), 0) + .mapNotNull { it.activityInfo?.applicationInfo } + .distinctBy { it.packageName } + launchable + .filter { it.packageName != context.packageName } + .map { + InstalledApp( + packageName = it.packageName, + label = runCatching { packageManager.getApplicationLabel(it).toString() } + .getOrDefault(it.packageName), + system = (it.flags and ApplicationInfo.FLAG_SYSTEM) != 0, + ) + } + .sortedBy { it.label.lowercase() } + } + } + } + + // ---- editor ------------------------------------------------------------ + + fun newProfile() { _draft.value = ProfileDraft() } + + fun editProfile(profile: BrickProfileEntity) { + viewModelScope.launch { + _draft.value = ProfileDraft( + id = profile.id, + name = profile.name, + blocked = profile.blockedPackages.lines().map { it.trim() }.filter { it.isNotEmpty() }.toSet(), + limits = brickRepository.limitsFor(profile.id), + activator = profile.activator, + deactivator = profile.deactivator, + nfcTagId = profile.nfcTagId, + startMinuteOfDay = profile.startMinuteOfDay, + endMinuteOfDay = profile.endMinuteOfDay, + strict = profile.strict, + ) + } + } + + fun updateDraft(transform: (ProfileDraft) -> ProfileDraft) { + _draft.value = _draft.value?.let(transform) + } + + fun closeEditor() { + _draft.value = null + _pairingTag.value = false + } + + fun startTagPairing() { _pairingTag.value = true } + + /** Called by the screen when a tag is read while the editor is pairing. */ + fun onTagPaired(tagId: String) { + _pairingTag.value = false + updateDraft { it.copy(nfcTagId = tagId, activator = "NFC", deactivator = "NFC") } + _message.value = "Tag paired" + } + + fun saveDraft() { + val draft = _draft.value ?: return + if (draft.name.isBlank() || draft.blocked.isEmpty()) { + _message.value = "Give the mode a name and pick at least one app" + return + } + if (draft.activator == "NFC" && draft.nfcTagId == null) { + _message.value = "Pair an NFC tag first, or pick another activator" + return + } + if (draft.activator == "TIME" && draft.startMinuteOfDay == null) { + _message.value = "Set a start time" + return + } + if (draft.deactivator == "TIME" && draft.endMinuteOfDay == null) { + _message.value = "Set an end time" + return + } + viewModelScope.launch { + brickRepository.saveProfile( + BrickProfileEntity( + id = draft.id, + name = draft.name.trim(), + blockedPackages = draft.blocked.joinToString("\n"), + activator = draft.activator, + deactivator = draft.deactivator, + nfcTagId = draft.nfcTagId, + startMinuteOfDay = draft.startMinuteOfDay, + endMinuteOfDay = draft.endMinuteOfDay, + strict = draft.strict, + createdAt = System.currentTimeMillis(), + ), + limits = draft.limits, + ) + _draft.value = null + _message.value = "Mode saved" + } + } + + fun deleteProfile(id: Long) { + viewModelScope.launch { + brickRepository.deleteProfile(id) + _message.value = "Mode deleted" + } + } + + // ---- session control --------------------------------------------------- + + fun startNow(profile: BrickProfileEntity) { + viewModelScope.launch { + if (!_serviceEnabled.value) { + _message.value = "Enable \"LifeOS Brick\" in accessibility settings first" + return@launch + } + val started = brickRepository.start(profile.id, "MANUAL") + _message.value = if (started) "\"${profile.name}\" is blocking now" else "Another mode is already running" + } + } + + fun stopNow() { + viewModelScope.launch { + val stopped = brickRepository.stop("MANUAL") + if (!stopped) { + val profile = brickRepository.active.value?.profile + _message.value = when (profile?.deactivator) { + "NFC" -> "Scan the paired tag to unlock this mode" + "TIME" -> "This mode unlocks at its end time" + else -> "This mode can't be stopped early" + } + } + } + } + + fun dismissMessage() { _message.value = null } + + fun openAccessibilitySettings() { + context.startActivity( + Intent(android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } +} diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickPolicy.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickPolicy.kt new file mode 100644 index 0000000..e739b5e --- /dev/null +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickPolicy.kt @@ -0,0 +1,76 @@ +package com.lifeos.feature.brick.data + +/** Why an app may open, or not. */ +sealed interface BlockDecision { + data object Allow : BlockDecision + /** Fully blocked by the running mode. */ + data class Blocked(val profileName: String, val unlockHint: String) : BlockDecision + /** Had a daily allowance, now spent. */ + data class LimitReached(val profileName: String, val dailyMinutes: Int) : BlockDecision +} + +/** What a mode looks like to the policy — no Android types, so it is unit-testable. */ +data class ModeRules( + val profileName: String, + val blockedPackages: Set, + /** package -> daily allowance in minutes; absent = hard block. */ + val limits: Map, + val deactivator: String, + val strict: Boolean, + val endMinuteOfDay: Int?, +) + +/** + * The rules that decide blocking and unlocking (§Module Brick), kept pure on + * purpose: this is the part that must never be wrong, so it is covered by unit + * tests instead of only by tapping a phone. + */ +object BrickPolicy { + + /** Internal override used when a profile disappears; bypasses strict rules. */ + const val FORCE = "__force__" + + /** + * Should [packageName] be allowed in the foreground? + * [usedSecondsToday] only matters for packages that carry an allowance. + */ + fun decide( + rules: ModeRules?, + packageName: String, + ownPackage: String, + usedSecondsToday: Long, + ): BlockDecision { + if (rules == null) return BlockDecision.Allow + // LifeOS itself is never blocked — otherwise you could not unlock. + if (packageName == ownPackage) return BlockDecision.Allow + if (packageName !in rules.blockedPackages) return BlockDecision.Allow + + val limit = rules.limits[packageName] + if (limit != null && limit > 0) { + return if (usedSecondsToday < limit * 60L) { + BlockDecision.Allow + } else { + BlockDecision.LimitReached(rules.profileName, limit) + } + } + return BlockDecision.Blocked(rules.profileName, unlockHint(rules)) + } + + /** Whether an end request coming from [by] satisfies the mode's exit rule. */ + fun canStop(rules: ModeRules, by: String): Boolean = when { + by == FORCE -> true + // Strict modes accept nothing but their configured condition. + rules.strict -> by == rules.deactivator + rules.deactivator == "MANUAL" -> true + else -> by == rules.deactivator + } + + fun unlockHint(rules: ModeRules): String = when (rules.deactivator) { + "NFC" -> "Scan your Brick tag to unlock" + "TIME" -> "Unlocks at ${formatMinute(rules.endMinuteOfDay)}" + else -> if (rules.strict) "Strict mode — wait it out" else "Stop the mode in LifeOS to unlock" + } + + fun formatMinute(minuteOfDay: Int?): String = + minuteOfDay?.let { "%02d:%02d".format(it / 60, it % 60) } ?: "the set time" +} diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickRepository.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickRepository.kt new file mode 100644 index 0000000..ad50d62 --- /dev/null +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickRepository.kt @@ -0,0 +1,234 @@ +package com.lifeos.feature.brick.data + +import android.content.Context +import com.lifeos.core.common.log.LifeLogger +import com.lifeos.core.database.brick.BrickAppLimitEntity +import com.lifeos.core.database.brick.BrickDao +import com.lifeos.core.database.brick.BrickProfileEntity +import com.lifeos.core.database.brick.BrickSessionEntity +import com.lifeos.core.database.brick.BrickUsageEntity +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Brick's brain (§Module Brick). Owns the running session, decides whether a + * package may be in the foreground, and tracks per-app daily usage so + * allowances ("30 min of Instagram") work without Usage Access. + * + * The hot path ([decide]) reads an in-memory snapshot so the accessibility + * service never touches disk on a window change. + */ +@Singleton +class BrickRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val brickDao: BrickDao, + private val scheduler: BrickScheduler, +) { + + /** Snapshot of the live mode, kept in memory for instant blocking checks. */ + data class ActiveMode( + val session: BrickSessionEntity, + val profile: BrickProfileEntity, + val blockedPackages: Set, + val limits: Map, + ) { + /** The pure view [BrickPolicy] reasons about. */ + val rules: ModeRules = ModeRules( + profileName = profile.name, + blockedPackages = blockedPackages, + limits = limits, + deactivator = profile.deactivator, + strict = profile.strict, + endMinuteOfDay = profile.endMinuteOfDay, + ) + } + + private val _active = MutableStateFlow(null) + val active = _active.asStateFlow() + + /** Seconds already spent today per package (in-memory mirror of brick_usage). */ + private val usageToday = mutableMapOf() + private var usageDate: String = today() + + fun observeProfiles(): Flow> = brickDao.observeProfiles() + fun observeSessions(): Flow> = brickDao.observeRecentSessions() + + /** Reloads the live session from disk (boot, process start, after edits). */ + suspend fun refresh() { + val session = brickDao.activeSession() + if (session == null) { + _active.value = null + } else { + val profile = brickDao.profile(session.profileId) + if (profile == null) { + // Profile deleted while running — close the orphan session. + brickDao.endAllSessions(System.currentTimeMillis()) + _active.value = null + } else { + _active.value = snapshot(session, profile) + } + } + loadUsage() + scheduler.rescheduleAll(brickDao.allProfiles()) + } + + private suspend fun snapshot(session: BrickSessionEntity, profile: BrickProfileEntity): ActiveMode { + val limits = brickDao.limitsFor(profile.id).associate { it.packageName to it.dailyMinutes } + return ActiveMode( + session = session, + profile = profile, + blockedPackages = profile.blockedPackages.lines().map { it.trim() }.filter { it.isNotEmpty() }.toSet(), + limits = limits, + ) + } + + /** Mirrors today's stored usage for the live mode's limited apps. */ + private suspend fun loadUsage() { + usageDate = today() + usageToday.clear() + val active = _active.value ?: return + active.limits.keys.forEach { pkg -> + brickDao.usage(usageDate, pkg)?.let { usageToday[pkg] = it.secondsUsed } + } + } + + // ---- session control --------------------------------------------------- + + suspend fun start(profileId: Long, startedBy: String): Boolean { + val profile = brickDao.profile(profileId) ?: return false + if (_active.value != null) return false + val session = BrickSessionEntity( + profileId = profileId, + startedAt = System.currentTimeMillis(), + startedBy = startedBy, + ) + val id = brickDao.insertSession(session) + _active.value = snapshot(session.copy(id = id), profile) + loadUsage() + LifeLogger.i(TAG, "Brick mode \"${profile.name}\" started by $startedBy") + return true + } + + /** + * Ends the live mode. Returns false when [by] doesn't satisfy the profile's + * deactivator — that's what makes strict modes actually strict. + */ + suspend fun stop(by: String): Boolean { + val active = _active.value ?: return true + if (!BrickPolicy.canStop(active.rules, by)) return false + brickDao.endAllSessions(System.currentTimeMillis()) + _active.value = null + LifeLogger.i(TAG, "Brick mode ended by $by") + return true + } + + /** NFC tap: flips the mode bound to [tagId]. Returns a message for the UI. */ + suspend fun onTagScanned(tagId: String): String { + val running = _active.value + if (running != null) { + return if (running.profile.nfcTagId == tagId) { + if (stop("NFC")) "\"${running.profile.name}\" unlocked" else "Wrong tag for this mode" + } else { + "\"${running.profile.name}\" stays locked — that's a different tag" + } + } + val profile = brickDao.profileByTag(tagId) + ?: return "Unknown tag. Pair it with a mode in Brick first." + return if (start(profile.id, "NFC")) "\"${profile.name}\" is now blocking" else "Couldn't start the mode" + } + + // ---- the hot path ------------------------------------------------------ + + /** Should [packageName] be allowed in the foreground right now? */ + fun decide(packageName: String): BlockDecision { + val active = _active.value + return BrickPolicy.decide( + rules = active?.rules, + packageName = packageName, + ownPackage = context.packageName, + usedSecondsToday = usageToday[packageName] ?: 0L, + ) + } + + /** Credits [seconds] of foreground time to a limited app. */ + suspend fun addUsage(packageName: String, seconds: Long) { + if (seconds <= 0) return + if (usageDate != today()) { + usageDate = today() + usageToday.clear() + } + val total = (usageToday[packageName] ?: 0L) + seconds + usageToday[packageName] = total + brickDao.upsertUsage(BrickUsageEntity(usageDate, packageName, total)) + } + + suspend fun noteBlockedAttempt() { + _active.value?.let { brickDao.incrementAttempts(it.session.id) } + } + + // ---- profile CRUD ------------------------------------------------------ + + suspend fun saveProfile(profile: BrickProfileEntity, limits: Map): Long { + val id = if (profile.id == 0L) { + brickDao.insertProfile(profile) + } else { + brickDao.updateProfile(profile) + profile.id + } + // Replace the limit set wholesale — simplest correct behaviour on edit. + brickDao.limitsFor(id).forEach { brickDao.deleteLimit(id, it.packageName) } + limits.forEach { (pkg, minutes) -> brickDao.upsertLimit(BrickAppLimitEntity(id, pkg, minutes)) } + scheduler.rescheduleAll(brickDao.allProfiles()) + // Keep a running session's snapshot in sync with the edit. + _active.value?.let { current -> + if (current.profile.id == id) { + brickDao.profile(id)?.let { _active.value = snapshot(current.session, it) } + } + } + return id + } + + suspend fun limitsFor(profileId: Long): Map = + brickDao.limitsFor(profileId).associate { it.packageName to it.dailyMinutes } + + suspend fun deleteProfile(id: Long) { + if (_active.value?.profile?.id == id) stop(FORCE) + brickDao.deleteProfile(id) + scheduler.rescheduleAll(brickDao.allProfiles()) + } + + suspend fun profile(id: Long): BrickProfileEntity? = brickDao.profile(id) + + /** Time-window tick from [BrickScheduleReceiver]: start or end as due. */ + suspend fun onScheduleTick(profileId: Long, starting: Boolean) { + val profile = brickDao.profile(profileId) ?: return + if (starting) { + if (_active.value == null && profile.activator == "TIME") start(profileId, "TIME") + } else { + val running = _active.value + if (running?.profile?.id == profileId) stop("TIME") + } + scheduler.rescheduleAll(brickDao.allProfiles()) + } + + private fun today(): String = DATE.format(Date()) + + companion object { + /** Internal override used when a profile disappears; bypasses strict rules. */ + const val FORCE = BrickPolicy.FORCE + private const val TAG = "BrickRepository" + private val DATE = SimpleDateFormat("yyyy-MM-dd", Locale.US) + + fun minuteOfDayNow(): Int = Calendar.getInstance().let { + it.get(Calendar.HOUR_OF_DAY) * 60 + it.get(Calendar.MINUTE) + } + } +} diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickScheduler.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickScheduler.kt new file mode 100644 index 0000000..f39a915 --- /dev/null +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickScheduler.kt @@ -0,0 +1,111 @@ +package com.lifeos.feature.brick.data + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.lifeos.core.common.log.LifeLogger +import com.lifeos.core.database.brick.BrickProfileEntity +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.Calendar +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Arms the next start/end alarm for every TIME-activated profile (§Module + * Brick). Inexact-but-idle-safe alarms are enough here: a mode beginning a + * minute late is fine, and it avoids burning the exact-alarm budget. + */ +@Singleton +class BrickScheduler @Inject constructor( + @ApplicationContext private val context: Context, +) { + + private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + + fun rescheduleAll(profiles: List) { + profiles.forEach { profile -> + cancel(profile.id, starting = true) + cancel(profile.id, starting = false) + if (profile.activator != "TIME" && profile.deactivator != "TIME") return@forEach + profile.startMinuteOfDay?.takeIf { profile.activator == "TIME" }?.let { + schedule(profile.id, it, starting = true) + } + profile.endMinuteOfDay?.takeIf { profile.deactivator == "TIME" }?.let { + schedule(profile.id, it, starting = false) + } + } + } + + private fun schedule(profileId: Long, minuteOfDay: Int, starting: Boolean) { + val next = nextOccurrence(minuteOfDay) + runCatching { + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, next, pendingIntent(profileId, starting)) + }.onFailure { LifeLogger.w(TAG, "Could not arm Brick alarm", it) } + } + + private fun cancel(profileId: Long, starting: Boolean) { + alarmManager.cancel(pendingIntent(profileId, starting)) + } + + private fun pendingIntent(profileId: Long, starting: Boolean): PendingIntent = + PendingIntent.getBroadcast( + context, + // Distinct request codes per profile and edge. + (profileId.toInt() * 2) + if (starting) 0 else 1, + Intent(context, BrickScheduleReceiver::class.java) + .setAction(if (starting) BrickScheduleReceiver.ACTION_START else BrickScheduleReceiver.ACTION_END) + .putExtra(BrickScheduleReceiver.EXTRA_PROFILE_ID, profileId), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + private fun nextOccurrence(minuteOfDay: Int): Long { + val now = System.currentTimeMillis() + val calendar = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, minuteOfDay / 60) + set(Calendar.MINUTE, minuteOfDay % 60) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + if (calendar.timeInMillis <= now) calendar.add(Calendar.DAY_OF_YEAR, 1) + return calendar.timeInMillis + } + + private companion object { + const val TAG = "BrickScheduler" + } +} + +/** Fires at a profile's start/end minute and flips the mode. */ +@AndroidEntryPoint +class BrickScheduleReceiver : BroadcastReceiver() { + + @Inject + lateinit var brickRepository: BrickRepository + + override fun onReceive(context: Context, intent: Intent) { + val profileId = intent.getLongExtra(EXTRA_PROFILE_ID, -1L) + if (profileId == -1L) return + val starting = intent.action == ACTION_START + val pending = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + brickRepository.refresh() + brickRepository.onScheduleTick(profileId, starting) + } finally { + pending.finish() + } + } + } + + companion object { + const val ACTION_START = "com.lifeos.brick.START" + const val ACTION_END = "com.lifeos.brick.END" + const val EXTRA_PROFILE_ID = "profile_id" + } +} diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/engine/BrickAccessibilityService.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/engine/BrickAccessibilityService.kt new file mode 100644 index 0000000..3f477f4 --- /dev/null +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/engine/BrickAccessibilityService.kt @@ -0,0 +1,133 @@ +package com.lifeos.feature.brick.engine + +import android.accessibilityservice.AccessibilityService +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.provider.Settings +import android.view.accessibility.AccessibilityEvent +import com.lifeos.core.common.log.LifeLogger +import com.lifeos.feature.brick.data.BlockDecision +import com.lifeos.feature.brick.data.BrickRepository +import com.lifeos.feature.brick.ui.BlockWallActivity +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 javax.inject.Inject + +/** + * The blocker (§Module Brick). Watches which app comes to the front and, while + * a Brick mode is running, replaces blocked apps with a full-screen wall — the + * same approach every no-root Android blocker uses (Family Link's supervision + * APIs are only available to Google's own package). + * + * Deliberately cheap: one window-state event in, one in-memory map lookup, no + * screen content read, nothing stored beyond foreground seconds for allowances. + */ +@AndroidEntryPoint +class BrickAccessibilityService : AccessibilityService() { + + @Inject + lateinit var brickRepository: BrickRepository + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** Foreground app + when it came forward, for daily-allowance accounting. */ + private var currentPackage: String? = null + private var currentSince: Long = 0L + private var lastWallAt: Long = 0L + + override fun onServiceConnected() { + super.onServiceConnected() + instance = this + scope.launch { brickRepository.refresh() } + LifeLogger.i(TAG, "Brick blocker connected") + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return + val packageName = event.packageName?.toString()?.takeIf { it.isNotBlank() } ?: return + // Ignore system UI churn (notification shade, keyboards, launcher chrome). + if (packageName == "com.android.systemui") return + + val now = System.currentTimeMillis() + creditPreviousApp(packageName, now) + + when (val decision = brickRepository.decide(packageName)) { + BlockDecision.Allow -> Unit + is BlockDecision.Blocked -> wall(decision.profileName, decision.unlockHint, now) + is BlockDecision.LimitReached -> wall( + decision.profileName, + "Daily limit of ${decision.dailyMinutes} min is used up", + now, + ) + } + } + + /** Adds the time the previous app spent in front to its allowance tally. */ + private fun creditPreviousApp(newPackage: String, now: Long) { + val previous = currentPackage + if (previous != null && previous != newPackage && currentSince > 0) { + val seconds = ((now - currentSince) / 1000L).coerceAtMost(3_600L) + if (seconds > 0) scope.launch { brickRepository.addUsage(previous, seconds) } + } + if (previous != newPackage) { + currentPackage = newPackage + currentSince = now + } + } + + private fun wall(profileName: String, hint: String, now: Long) { + // Debounce: one wall per second is plenty and avoids relaunch storms. + if (now - lastWallAt < 1_000L) return + lastWallAt = now + scope.launch { brickRepository.noteBlockedAttempt() } + // Home first so the blocked app leaves the foreground even if the wall + // is slow to draw; then show the wall over the launcher. + performGlobalAction(GLOBAL_ACTION_HOME) + runCatching { + startActivity( + BlockWallActivity.intent(this, profileName, hint) + .addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TASK or + Intent.FLAG_ACTIVITY_NO_ANIMATION, + ), + ) + }.onFailure { LifeLogger.w(TAG, "Could not show the block wall", it) } + } + + override fun onInterrupt() = Unit + + override fun onDestroy() { + if (instance === this) instance = null + scope.cancel() + super.onDestroy() + } + + companion object { + @Volatile + var instance: BrickAccessibilityService? = null + private set + + val isConnected: Boolean get() = instance != null + + /** True when the user has enabled Brick in accessibility settings. */ + fun isEnabledInSettings(context: Context): Boolean { + val enabled = Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + ) ?: return false + val component = ComponentName(context, BrickAccessibilityService::class.java) + return enabled.split(':').any { + it.equals(component.flattenToString(), ignoreCase = true) || + it.equals(component.flattenToShortString(), ignoreCase = true) + } + } + + private const val TAG = "BrickBlocker" + } +} diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/nfc/BrickNfcActivity.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/nfc/BrickNfcActivity.kt new file mode 100644 index 0000000..8d7238c --- /dev/null +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/nfc/BrickNfcActivity.kt @@ -0,0 +1,89 @@ +package com.lifeos.feature.brick.nfc + +import android.content.Intent +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.os.Bundle +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import com.lifeos.core.designsystem.theme.LifeOsTheme +import com.lifeos.feature.brick.data.BrickRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The tap target (§Module Brick): scanning a paired tag opens this, flips the + * matching mode, shows a toast and closes — the whole interaction is one tap, + * like Foqos on iOS. Works from the launcher or over another app. + */ +@AndroidEntryPoint +class BrickNfcActivity : FragmentActivity() { + + @Inject + lateinit var brickRepository: BrickRepository + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + LifeOsTheme { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + Text( + "Reading Brick tag…", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 16.dp), + ) + } + } + } + } + handle(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handle(intent) + } + + private fun handle(intent: Intent?) { + val tagId = intent?.tagId() + if (tagId == null) { + finish() + return + } + lifecycleScope.launch { + brickRepository.refresh() + val message = brickRepository.onTagScanned(tagId) + Toast.makeText(this@BrickNfcActivity, message, Toast.LENGTH_LONG).show() + finish() + } + } +} + +/** Hex id of the scanned tag, or null when the intent carries no tag. */ +internal fun Intent.tagId(): String? { + @Suppress("DEPRECATION") + val tag = getParcelableExtra(NfcAdapter.EXTRA_TAG) ?: return null + return tag.id?.joinToString("") { "%02X".format(it) }?.takeIf { it.isNotEmpty() } +} diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/ui/BlockWallActivity.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/ui/BlockWallActivity.kt new file mode 100644 index 0000000..e9a5f52 --- /dev/null +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/ui/BlockWallActivity.kt @@ -0,0 +1,109 @@ +package com.lifeos.feature.brick.ui + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Shield +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import com.lifeos.core.designsystem.theme.LifeOsTheme +import dagger.hilt.android.AndroidEntryPoint + +/** + * The wall shown instead of a blocked app (§Module Brick). Deliberately a dead + * end: no way back into the app, only "Home". Back is swallowed so hammering it + * can't slip past the block. + */ +@AndroidEntryPoint +class BlockWallActivity : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + val profileName = intent.getStringExtra(EXTRA_PROFILE).orEmpty() + val hint = intent.getStringExtra(EXTRA_HINT).orEmpty() + + // Swallow Back: leaving the wall must go Home, not back to the app. + onBackPressedDispatcher.addCallback(this, object : androidx.activity.OnBackPressedCallback(true) { + override fun handleOnBackPressed() = goHome() + }) + + setContent { + LifeOsTheme { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Filled.Shield, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(72.dp), + ) + Text( + "Blocked", + style = MaterialTheme.typography.displaySmall, + modifier = Modifier.padding(top = 16.dp), + ) + Text( + profileName.ifBlank { "A Brick mode is running" }, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp), + ) + Text( + hint, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 24.dp), + ) + Button( + onClick = { goHome() }, + modifier = Modifier.padding(top = 32.dp), + ) { Text("Home") } + } + } + } + } + } + + private fun goHome() { + startActivity( + Intent(Intent.ACTION_MAIN) + .addCategory(Intent.CATEGORY_HOME) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + finish() + } + + companion object { + private const val EXTRA_PROFILE = "profile" + private const val EXTRA_HINT = "hint" + + fun intent(context: Context, profileName: String, hint: String): Intent = + Intent(context, BlockWallActivity::class.java) + .putExtra(EXTRA_PROFILE, profileName) + .putExtra(EXTRA_HINT, hint) + } +} diff --git a/feature/brick/src/main/res/values/strings.xml b/feature/brick/src/main/res/values/strings.xml new file mode 100644 index 0000000..e6f1055 --- /dev/null +++ b/feature/brick/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Blocks the apps you chose while a Brick mode is running. LifeOS only checks which app is in front — no screen content is read, and nothing leaves the device. + diff --git a/feature/brick/src/main/res/xml/brick_accessibility_config.xml b/feature/brick/src/main/res/xml/brick_accessibility_config.xml new file mode 100644 index 0000000..cff4a7e --- /dev/null +++ b/feature/brick/src/main/res/xml/brick_accessibility_config.xml @@ -0,0 +1,13 @@ + + + diff --git a/feature/brick/src/main/res/xml/brick_nfc_tech.xml b/feature/brick/src/main/res/xml/brick_nfc_tech.xml new file mode 100644 index 0000000..0c88eb6 --- /dev/null +++ b/feature/brick/src/main/res/xml/brick_nfc_tech.xml @@ -0,0 +1,25 @@ + + + + + android.nfc.tech.NfcA + + + android.nfc.tech.NfcB + + + android.nfc.tech.NfcF + + + android.nfc.tech.NfcV + + + android.nfc.tech.MifareUltralight + + + android.nfc.tech.MifareClassic + + + android.nfc.tech.Ndef + + diff --git a/feature/brick/src/test/kotlin/com/lifeos/feature/brick/data/BrickPolicyTest.kt b/feature/brick/src/test/kotlin/com/lifeos/feature/brick/data/BrickPolicyTest.kt new file mode 100644 index 0000000..020292b --- /dev/null +++ b/feature/brick/src/test/kotlin/com/lifeos/feature/brick/data/BrickPolicyTest.kt @@ -0,0 +1,115 @@ +package com.lifeos.feature.brick.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The rules that must never be wrong: what gets blocked, and what unlocks it. */ +class BrickPolicyTest { + + private val socials = ModeRules( + profileName = "No socials", + blockedPackages = setOf("com.instagram.android", "com.zhiliaoapp.musically"), + limits = emptyMap(), + deactivator = "NFC", + strict = false, + endMinuteOfDay = null, + ) + + @Test + fun `no running mode allows everything`() { + assertEquals( + BlockDecision.Allow, + BrickPolicy.decide(null, "com.instagram.android", OWN, usedSecondsToday = 0), + ) + } + + @Test + fun `blocked package is blocked while the mode runs`() { + val decision = BrickPolicy.decide(socials, "com.instagram.android", OWN, 0) + assertTrue(decision is BlockDecision.Blocked) + assertEquals("No socials", (decision as BlockDecision.Blocked).profileName) + assertEquals("Scan your Brick tag to unlock", decision.unlockHint) + } + + @Test + fun `unlisted package stays allowed`() { + assertEquals(BlockDecision.Allow, BrickPolicy.decide(socials, "com.android.deskclock", OWN, 0)) + } + + @Test + fun `lifeos itself is never blocked so unlocking stays possible`() { + val rules = socials.copy(blockedPackages = socials.blockedPackages + OWN) + assertEquals(BlockDecision.Allow, BrickPolicy.decide(rules, OWN, OWN, 0)) + } + + @Test + fun `allowance lets the app through until the minutes are spent`() { + val rules = socials.copy(limits = mapOf("com.instagram.android" to 30)) + // 29 minutes in: still fine. + assertEquals( + BlockDecision.Allow, + BrickPolicy.decide(rules, "com.instagram.android", OWN, usedSecondsToday = 29 * 60L), + ) + // Exactly at the limit: done for the day. + val spent = BrickPolicy.decide(rules, "com.instagram.android", OWN, usedSecondsToday = 30 * 60L) + assertTrue(spent is BlockDecision.LimitReached) + assertEquals(30, (spent as BlockDecision.LimitReached).dailyMinutes) + } + + @Test + fun `a zero minute allowance is a hard block not an open door`() { + val rules = socials.copy(limits = mapOf("com.instagram.android" to 0)) + assertTrue(BrickPolicy.decide(rules, "com.instagram.android", OWN, 0) is BlockDecision.Blocked) + } + + @Test + fun `an allowance on one app does not leak to another blocked app`() { + val rules = socials.copy(limits = mapOf("com.instagram.android" to 30)) + assertTrue( + BrickPolicy.decide(rules, "com.zhiliaoapp.musically", OWN, usedSecondsToday = 0) + is BlockDecision.Blocked, + ) + } + + // ---- exit rules -------------------------------------------------------- + + @Test + fun `nfc mode only ends on an nfc tap`() { + assertTrue(BrickPolicy.canStop(socials, "NFC")) + assertFalse(BrickPolicy.canStop(socials, "MANUAL")) + assertFalse(BrickPolicy.canStop(socials, "TIME")) + } + + @Test + fun `manual mode ends by hand`() { + val manual = socials.copy(deactivator = "MANUAL") + assertTrue(BrickPolicy.canStop(manual, "MANUAL")) + } + + @Test + fun `strict manual mode cannot be ended by any other trigger`() { + val strictTime = socials.copy(deactivator = "TIME", strict = true, endMinuteOfDay = 20 * 60) + assertFalse(BrickPolicy.canStop(strictTime, "MANUAL")) + assertFalse(BrickPolicy.canStop(strictTime, "NFC")) + assertTrue(BrickPolicy.canStop(strictTime, "TIME")) + } + + @Test + fun `force always wins so a deleted profile cannot wedge the phone`() { + val strictNfc = socials.copy(strict = true) + assertTrue(BrickPolicy.canStop(strictNfc, BrickPolicy.FORCE)) + } + + @Test + fun `time mode tells the user when it opens up again`() { + val rules = socials.copy(deactivator = "TIME", endMinuteOfDay = 20 * 60 + 5) + val decision = BrickPolicy.decide(rules, "com.instagram.android", OWN, 0) + assertEquals("Unlocks at 20:05", (decision as BlockDecision.Blocked).unlockHint) + } + + private companion object { + const val OWN = "com.lifeos" + } +} diff --git a/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/data/ScreenTimeCollector.kt b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/data/ScreenTimeCollector.kt index a7d1865..d8b174e 100644 --- a/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/data/ScreenTimeCollector.kt +++ b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/data/ScreenTimeCollector.kt @@ -49,9 +49,16 @@ class ScreenTimeCollector @Inject constructor( if (!hasPermission()) return@withContext val usageManager = context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager val packageManager = context.packageManager + // Stored days never change again, so only fetch what is missing — plus + // today and yesterday, which are still accumulating. + val alreadyStored = dao.capturedDates().toSet() for (offset in 0 until days) { - val (dayStart, dayEnd, dateKey) = dayBounds(offset) + val bounds = dayBounds(offset) + val dayStart = bounds.start + val dayEnd = bounds.end + val dateKey = bounds.key + if (offset > 1 && dateKey in alreadyStored) continue val stats = usageManager.queryAndAggregateUsageStats(dayStart, dayEnd) if (stats.isEmpty()) continue diff --git a/settings.gradle.kts b/settings.gradle.kts index afe67b3..d34946b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -52,6 +52,7 @@ include(":feature:plants") include(":feature:news") include(":feature:vault") include(":feature:screentime") +include(":feature:brick") include(":core:model") include(":core:network") include(":core:ai")