diff --git a/README.md b/README.md
index 65e28e8..25212f2 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,15 @@ and rule traces back to a section (and often a community demand source) there.
| Focus timer keeps running: state lives in a singleton driven by an absolute deadline, so switching tabs, going Home or leaving the app no longer resets it. The time in the ring is edited in place (no dialog), and the overlay can be dragged anywhere and pinched to resize; closing it with its X now updates the in-app button | Done |
| Pastebin honesty pass: its developer API has neither burn-after-read nor paste passwords, so those requests now go to PrivateBin instead - AES-256-GCM encrypted on the phone, key in the link fragment, verified against a live instance. Maintenance pages and bad logins are reported instead of being mistaken for success, and signed-in pastes land in the account | Done |
| Home tiles auto-scroll while you drag one near the top or bottom edge | Done |
+| Triggers (new module): visible automation rules - when a time, place arrival/exit, Wi-Fi, NFC tag, notification, screen-time threshold, battery level or reminder happens, do any LifeOS action or macro. Day-of-week and window conditions, a one-a-minute cooldown, run-by-hand testing, and a fire log that answers "why did that happen". Jarvis reads and writes rules | Done |
+| Places (new core module): home/work/gym recognition without Play Services - Wi-Fi network match plus last-known-location inside a radius, polled every two minutes with no active GPS fix. Publishes enter/leave on the event bus, which is what Triggers listens to | Done |
+| Signals (new module): notification capture with dedupe, code/parcel/receipt tagging, a "what did I miss" digest and 30-day trimming, behind a single notification-access grant. Jarvis answers from it and rules can fire on it | Done |
+| Recall (new core module): semantic index over notes, captures, memex clips, chat history and readable /LifeOS files, embedded on-device and refreshed incrementally. Jarvis searches it with `[[get: recall \| query]]` instead of keyword matching | Done |
+| Sync (new module): encrypted database snapshots (VACUUM INTO, AES-256-GCM from a passphrase), kept in generations locally and pushed to WebDAV on the NAS, each run read back and decrypted to verify it. Restore is staged and applied before Room opens on the next cold start | Done |
+| Jarvis core v2: token streaming through MediaPipe's progress listener (first-token latency instead of total), multi-hop tool use (up to two module reads per turn, each answered in a fresh pass) and cleaned final text so raw fragments never show | Done |
+| Plan: auto-schedules due tasks into real calendar gaps inside working hours as ordinary, movable events; Jarvis plans today or tomorrow on request | Done |
+| Surfaces: a "Next up" Glance home-screen widget plus quick-settings tiles for capture and a 25-minute focus block | Done |
+| Voice: replies can be spoken through the device's on-device speech engine, and any module (or Jarvis) can say something with a Speak action | Done |
| 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 | Planned |
**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.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ea1b844..93b80bb 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -10,8 +10,8 @@ android {
defaultConfig {
applicationId = "com.lifeos"
- versionCode = 22
- versionName = "0.1.0-alpha.22"
+ versionCode = 23
+ versionName = "0.1.0-alpha.23"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -89,6 +89,13 @@ dependencies {
implementation(projects.feature.brick)
implementation(projects.feature.pastebin)
implementation(projects.feature.clearsky)
+ implementation(projects.feature.triggers)
+ implementation(projects.feature.signals)
+ implementation(projects.feature.sync)
+ implementation(projects.core.places)
+ implementation(projects.core.recall)
+ implementation(projects.core.voice)
+
implementation(projects.core.model)
implementation(projects.core.network)
@@ -103,6 +110,8 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.glance.appwidget)
+ implementation(libs.androidx.glance.material3)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 025d892..9437f32 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -69,6 +69,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/com/lifeos/app/LifeOsApplication.kt b/app/src/main/kotlin/com/lifeos/app/LifeOsApplication.kt
index 9cf5c27..0bb2e17 100644
--- a/app/src/main/kotlin/com/lifeos/app/LifeOsApplication.kt
+++ b/app/src/main/kotlin/com/lifeos/app/LifeOsApplication.kt
@@ -3,8 +3,18 @@ package com.lifeos.app
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
+import com.lifeos.core.database.LifeDatabase
+import com.lifeos.core.places.PlaceEngine
+import com.lifeos.core.recall.RecallIndex
import com.lifeos.feature.dhl.work.PackagePollWorker
+import com.lifeos.feature.sync.data.BackupService
+import com.lifeos.feature.triggers.data.TriggerEngine
import dagger.hilt.android.HiltAndroidApp
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltAndroidApp
@@ -13,11 +23,39 @@ class LifeOsApplication : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
+ @Inject
+ lateinit var triggerEngine: TriggerEngine
+
+ @Inject
+ lateinit var placeEngine: PlaceEngine
+
+ @Inject
+ lateinit var recallIndex: RecallIndex
+
+ private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
override val workManagerConfiguration: Configuration
get() = Configuration.Builder().setWorkerFactory(workerFactory).build()
override fun onCreate() {
+ // A staged restore has to be applied before anything opens the database,
+ // which is why this is the very first thing the process does.
+ BackupService.applyStagedRestore(this, LifeDatabase.NAME)
super.onCreate()
PackagePollWorker.schedule(this)
+ // Rules and place matching are the two things that must run whether or
+ // not their screens were ever opened.
+ placeEngine.start()
+ triggerEngine.start()
+ startupScope.launch {
+ // Poll the state-ish triggers (Wi-Fi, battery) and keep the semantic
+ // index warm, both well after start-up so nothing competes with it.
+ delay(20_000)
+ while (true) {
+ runCatching { triggerEngine.poll() }
+ runCatching { recallIndex.reindex() }
+ delay(15 * 60_000L)
+ }
+ }
}
}
diff --git a/app/src/main/kotlin/com/lifeos/app/surfaces/LifeOsTiles.kt b/app/src/main/kotlin/com/lifeos/app/surfaces/LifeOsTiles.kt
new file mode 100644
index 0000000..9b50165
--- /dev/null
+++ b/app/src/main/kotlin/com/lifeos/app/surfaces/LifeOsTiles.kt
@@ -0,0 +1,72 @@
+package com.lifeos.app.surfaces
+
+import android.content.Intent
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import com.lifeos.app.MainActivity
+import com.lifeos.core.model.LifeModule
+import com.lifeos.core.model.SourceRef
+import com.lifeos.core.service.LifeAction
+import com.lifeos.core.service.LifeActionDispatcher
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Quick-settings tiles (§Module Surfaces): the two things worth doing without
+ * opening anything - capture a thought, and start a focus block.
+ */
+@AndroidEntryPoint
+class QuickCaptureTileService : TileService() {
+
+ override fun onStartListening() {
+ qsTile?.apply {
+ state = Tile.STATE_INACTIVE
+ label = "LifeOS capture"
+ updateTile()
+ }
+ }
+
+ override fun onClick() {
+ val intent = Intent(this, MainActivity::class.java)
+ .putExtra(EXTRA_QUICK_CAPTURE, true)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ @Suppress("DEPRECATION")
+ startActivityAndCollapse(intent)
+ }
+
+ companion object {
+ const val EXTRA_QUICK_CAPTURE = "lifeos.quick_capture"
+ }
+}
+
+/** Starts a 25-minute focus block straight from the shade. */
+@AndroidEntryPoint
+class FocusTileService : TileService() {
+
+ @Inject
+ lateinit var dispatcher: LifeActionDispatcher
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun onStartListening() {
+ qsTile?.apply {
+ state = Tile.STATE_INACTIVE
+ label = "Focus 25 min"
+ updateTile()
+ }
+ }
+
+ override fun onClick() {
+ scope.launch {
+ dispatcher.dispatch(LifeAction.StartFocusTimer(25, SourceRef(LifeModule.ADHD, "tile")))
+ }
+ qsTile?.apply {
+ state = Tile.STATE_ACTIVE
+ updateTile()
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/lifeos/app/surfaces/NextUpWidget.kt b/app/src/main/kotlin/com/lifeos/app/surfaces/NextUpWidget.kt
new file mode 100644
index 0000000..5dc24d5
--- /dev/null
+++ b/app/src/main/kotlin/com/lifeos/app/surfaces/NextUpWidget.kt
@@ -0,0 +1,97 @@
+package com.lifeos.app.surfaces
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.action.actionStartActivity
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+import androidx.glance.layout.Column
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.padding
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import androidx.compose.ui.unit.dp
+import androidx.glance.unit.ColorProvider
+import com.lifeos.app.MainActivity
+import com.lifeos.core.database.calendar.CalendarDao
+import com.lifeos.core.database.capture.CaptureDao
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.flow.first
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/**
+ * "Next up" home-screen widget (§Module Surfaces).
+ *
+ * Reads the same DAOs the app does through a Hilt entry point - a widget is not
+ * an Android component Hilt can inject directly. Tapping it opens LifeOS.
+ */
+class NextUpWidget : GlanceAppWidget() {
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val lines = loadLines(context)
+ provideContent {
+ WidgetBody(lines)
+ }
+ }
+
+ @Composable
+ private fun WidgetBody(lines: List) {
+ GlanceTheme {
+ Column(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(GlanceTheme.colors.widgetBackground)
+ .padding(12.dp)
+ .clickable(actionStartActivity()),
+ ) {
+ Text(
+ "Next up",
+ style = TextStyle(color = ColorProvider(TITLE)),
+ )
+ lines.forEach { line ->
+ Text(line, style = TextStyle(color = GlanceTheme.colors.onSurface))
+ }
+ }
+ }
+ }
+
+ private suspend fun loadLines(context: Context): List = runCatching {
+ val entryPoint = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java)
+ val now = System.currentTimeMillis()
+ val events = entryPoint.calendarDao().observeUpcoming(now, limit = 2).first()
+ val tasks = entryPoint.captureDao().observeTasks().first().filter { !it.done }
+ buildList {
+ events.forEach { add("${AT.format(Date(it.startsAt))} ${it.title.take(28)}") }
+ tasks.take(3 - events.size.coerceAtMost(2)).forEach { add("- ${it.title.take(30)}") }
+ if (isEmpty()) add("Nothing scheduled")
+ }
+ }.getOrElse { listOf("LifeOS") }
+
+ private companion object {
+ val AT = SimpleDateFormat("EEE HH:mm", Locale.getDefault())
+ val TITLE = androidx.compose.ui.graphics.Color(0xFF9FCBA6)
+ }
+}
+
+/** Receiver the system talks to. */
+class NextUpWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = NextUpWidget()
+}
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface WidgetEntryPoint {
+ fun calendarDao(): CalendarDao
+ fun captureDao(): CaptureDao
+}
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 d876152..194592a 100644
--- a/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt
+++ b/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt
@@ -56,6 +56,9 @@ import com.lifeos.feature.vault.VaultRoute
import com.lifeos.feature.screentime.ScreenTimeRoute
import com.lifeos.feature.brick.BrickRoute
import com.lifeos.feature.clearsky.ClearSkyRoute
+import com.lifeos.feature.signals.SignalsRoute
+import com.lifeos.feature.sync.SyncRoute
+import com.lifeos.feature.triggers.TriggersRoute
import com.lifeos.feature.pastebin.PastebinRoute
/**
@@ -194,6 +197,9 @@ fun LifeOsApp(captureRequests: Int = 0, navBarIds: List = emptyList()) {
composable { BrickRoute() }
composable { PastebinRoute() }
composable { ClearSkyRoute() }
+ composable { TriggersRoute() }
+ composable { SignalsRoute() }
+ composable { SyncRoute() }
}
}
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 3e3b9e3..81ef344 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
@@ -24,7 +24,10 @@ import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Bolt
+import androidx.compose.material.icons.filled.Bolt
+import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.ContentPaste
+import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.DocumentScanner
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.GridView
@@ -224,6 +227,24 @@ fun HomeScreen(
icon = Icons.Filled.ContentPaste,
destination = LifeDestination.Pastebin,
),
+ AppGridItem(
+ label = "Triggers",
+ description = "When this happens, do that",
+ icon = Icons.Filled.Bolt,
+ destination = LifeDestination.Triggers,
+ ),
+ AppGridItem(
+ label = "Signals",
+ description = "What you missed, grouped",
+ icon = Icons.Filled.Notifications,
+ destination = LifeDestination.Signals,
+ ),
+ AppGridItem(
+ label = "Sync",
+ description = "Encrypted backups to your NAS",
+ icon = Icons.Filled.CloudUpload,
+ destination = LifeDestination.Sync,
+ ),
AppGridItem(
label = "Clear Sky Map",
description = "Stargazing forecast for any spot",
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 81b1013..e8cec09 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", "Brick", "Screen Time", "Pastebin", "Clear Sky Map",
+ "Downloader", "Plants", "News", "Brick", "Screen Time", "Pastebin", "Clear Sky Map", "Triggers", "Signals", "Sync",
)
}
}
diff --git a/app/src/main/res/xml/next_up_widget_info.xml b/app/src/main/res/xml/next_up_widget_info.xml
new file mode 100644
index 0000000..a1a7004
--- /dev/null
+++ b/app/src/main/res/xml/next_up_widget_info.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/core/ai/src/main/kotlin/com/lifeos/core/ai/AiRouter.kt b/core/ai/src/main/kotlin/com/lifeos/core/ai/AiRouter.kt
index ef38602..0255ef2 100644
--- a/core/ai/src/main/kotlin/com/lifeos/core/ai/AiRouter.kt
+++ b/core/ai/src/main/kotlin/com/lifeos/core/ai/AiRouter.kt
@@ -5,6 +5,7 @@ import com.lifeos.core.ai.model.AiChunk
import com.lifeos.core.ai.model.AiCompletion
import com.lifeos.core.ai.model.AiEngineId
import com.lifeos.core.ai.model.AiRequest
+import com.lifeos.core.ai.model.REPLACE_ALL
import com.lifeos.core.common.log.LifeLogger
import com.lifeos.core.common.result.LifeError
import com.lifeos.core.common.result.LifeResult
@@ -95,7 +96,10 @@ class AiRouter(
engine = event.engine
text.setLength(0)
}
- is StreamEvent.Chunk -> text.append(event.chunk.text)
+ is StreamEvent.Chunk ->
+ // Streaming engines finish with a sentinel plus the cleaned
+ // text; keep only that so callers never see raw fragments.
+ if (event.chunk.text == REPLACE_ALL) text.setLength(0) else text.append(event.chunk.text)
is StreamEvent.Failed -> error = event.error
}
}
diff --git a/core/ai/src/main/kotlin/com/lifeos/core/ai/engine/gemma/GemmaEngine.kt b/core/ai/src/main/kotlin/com/lifeos/core/ai/engine/gemma/GemmaEngine.kt
index 03d1254..37607ea 100644
--- a/core/ai/src/main/kotlin/com/lifeos/core/ai/engine/gemma/GemmaEngine.kt
+++ b/core/ai/src/main/kotlin/com/lifeos/core/ai/engine/gemma/GemmaEngine.kt
@@ -12,11 +12,16 @@ import com.lifeos.core.ai.model.AiChunk
import com.lifeos.core.ai.model.AiEngineId
import com.lifeos.core.ai.model.AiRequest
import com.lifeos.core.ai.model.AiRole
+import com.lifeos.core.ai.model.REPLACE_ALL
import com.lifeos.core.common.coroutines.DispatcherProvider
import com.lifeos.core.common.log.LifeLogger
import com.lifeos.core.datastore.AiConfigRepository
import dagger.hilt.android.qualifiers.ApplicationContext
+import com.google.mediapipe.tasks.genai.llminference.ProgressListener
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.channels.ProducerScope
+import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
@@ -66,19 +71,22 @@ class GemmaEngine @Inject constructor(
* error or timeout releases the model and surfaces a clean failure instead
* of wedging — a hung inference can never brick the device again.
*/
- override fun stream(request: AiRequest): Flow = flow {
+ override fun stream(request: AiRequest): Flow = channelFlow {
val file = modelFile()
check(file != null && file.exists()) { "No on-device model at ${file?.absolutePath}" }
val images = request.messages.flatMap { it.imagePaths }.takeLast(MAX_IMAGES)
- val text = mutex.withLock {
+ mutex.withLock {
try {
withTimeout(GENERATE_TIMEOUT_MS) {
val inference = loadIfNeeded(file.absolutePath, withVision = images.isNotEmpty())
if (images.isEmpty()) {
- inference.generateResponse(buildPrompt(request))
+ streamText(inference, buildPrompt(request))
} else {
- generateWithImages(inference, buildPrompt(request), images)
+ // Vision goes through a session, which has no progress
+ // callback; one chunk is the honest shape there.
+ val text = generateWithImages(inference, buildPrompt(request), images)
+ send(AiChunk(text = sanitize(text), done = true))
}
}
} catch (t: Throwable) {
@@ -90,9 +98,38 @@ class GemmaEngine @Inject constructor(
throw t
}
}
- emit(AiChunk(text = sanitize(text), done = true))
}.flowOn(inferenceDispatcher)
+ /**
+ * Token streaming (§Module 9 v2). MediaPipe calls the progress listener with
+ * each new fragment, so the reply appears as it is decoded instead of after
+ * the whole thing is done — first-token latency replaces total latency as the
+ * felt cost. Partials are sanitized individually and the accumulated text is
+ * cleaned once at the end, because turn tokens can straddle two fragments.
+ */
+ private suspend fun ProducerScope.streamText(inference: LlmInference, prompt: String) {
+ val accumulated = StringBuilder()
+ val finished = CompletableDeferred()
+ val listener = ProgressListener { partial, done ->
+ if (partial != null) {
+ accumulated.append(partial)
+ trySend(AiChunk(text = partial, done = false))
+ }
+ if (done) finished.complete(Unit)
+ }
+ val future = inference.generateResponseAsync(prompt, listener)
+ try {
+ finished.await()
+ } finally {
+ runCatching { future.get() }
+ }
+ // One last chunk carries the cleaned full text so callers that keep only
+ // the final value (and the sanitizer) still see a coherent answer.
+ val clean = sanitize(accumulated.toString())
+ send(AiChunk(text = REPLACE_ALL, done = false))
+ send(AiChunk(text = clean, done = true))
+ }
+
/** Frees the model memory (called from onTrimMemory via the app). */
suspend fun release() = mutex.withLock {
runCatching { llm?.close() }
diff --git a/core/ai/src/main/kotlin/com/lifeos/core/ai/model/AiModels.kt b/core/ai/src/main/kotlin/com/lifeos/core/ai/model/AiModels.kt
index 2feb7d5..de96b82 100644
--- a/core/ai/src/main/kotlin/com/lifeos/core/ai/model/AiModels.kt
+++ b/core/ai/src/main/kotlin/com/lifeos/core/ai/model/AiModels.kt
@@ -28,12 +28,22 @@ data class AiRequest(
val localOnly: Boolean = false,
)
-/** One streamed increment of a completion. */
+/**
+ * One streamed increment of a completion.
+ *
+ * A chunk whose text equals [REPLACE_ALL] tells the consumer to clear what it
+ * has accumulated: the next chunk carries the whole cleaned answer. Streaming
+ * engines use it to hand over a sanitized final text without re-emitting every
+ * fragment.
+ */
data class AiChunk(
val text: String,
val done: Boolean,
)
+/** Sentinel chunk text: discard accumulated output, the next chunk replaces it. */
+const val REPLACE_ALL = "\u0000LIFEOS_REPLACE_ALL\u0000"
+
data class AiCompletion(
val text: String,
val engine: AiEngineId,
diff --git a/core/database/schemas/com.lifeos.core.database.LifeDatabase/17.json b/core/database/schemas/com.lifeos.core.database.LifeDatabase/17.json
new file mode 100644
index 0000000..4a5cafb
--- /dev/null
+++ b/core/database/schemas/com.lifeos.core.database.LifeDatabase/17.json
@@ -0,0 +1,2460 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 17,
+ "identityHash": "cf4620bb9fca2d2a552007faa7ff59c1",
+ "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, `imagePaths` TEXT NOT NULL DEFAULT '', 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
+ },
+ {
+ "fieldPath": "imagePaths",
+ "columnName": "imagePaths",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ }
+ ],
+ "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, `inverse` INTEGER NOT NULL DEFAULT 0, `unlockMinutes` INTEGER NOT NULL DEFAULT 60, `unlockAllowance` INTEGER NOT NULL DEFAULT 1, `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": "inverse",
+ "columnName": "inverse",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "unlockMinutes",
+ "columnName": "unlockMinutes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "60"
+ },
+ {
+ "fieldPath": "unlockAllowance",
+ "columnName": "unlockAllowance",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "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, `unlockUntil` INTEGER, `unlocksUsed` INTEGER NOT NULL DEFAULT 0)",
+ "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
+ },
+ {
+ "fieldPath": "unlockUntil",
+ "columnName": "unlockUntil",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "unlocksUsed",
+ "columnName": "unlocksUsed",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "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"
+ ]
+ }
+ },
+ {
+ "tableName": "places",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `latitude` REAL, `longitude` REAL, `radiusMeters` INTEGER NOT NULL, `wifiSsid` TEXT, `createdAt` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "radiusMeters",
+ "columnName": "radiusMeters",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "wifiSsid",
+ "columnName": "wifiSsid",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "trigger_rules",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `triggerType` TEXT NOT NULL, `triggerArg` TEXT NOT NULL, `days` TEXT NOT NULL, `window` TEXT NOT NULL, `actionType` TEXT NOT NULL, `actionArg` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `lastFiredAt` INTEGER, `fireCount` 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": "triggerType",
+ "columnName": "triggerType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "triggerArg",
+ "columnName": "triggerArg",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "days",
+ "columnName": "days",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "window",
+ "columnName": "window",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "actionType",
+ "columnName": "actionType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "actionArg",
+ "columnName": "actionArg",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "enabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastFiredAt",
+ "columnName": "lastFiredAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "fireCount",
+ "columnName": "fireCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "trigger_fires",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ruleId` INTEGER NOT NULL, `ruleName` TEXT NOT NULL, `at` INTEGER NOT NULL, `outcome` TEXT NOT NULL, `detail` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ruleId",
+ "columnName": "ruleId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "ruleName",
+ "columnName": "ruleName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "at",
+ "columnName": "at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "outcome",
+ "columnName": "outcome",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "detail",
+ "columnName": "detail",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "signals",
+ "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 NOT NULL, `text` TEXT NOT NULL, `postedAt` INTEGER NOT NULL, `readAt` INTEGER, `extracted` TEXT 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",
+ "notNull": true
+ },
+ {
+ "fieldPath": "text",
+ "columnName": "text",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "postedAt",
+ "columnName": "postedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "readAt",
+ "columnName": "readAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "extracted",
+ "columnName": "extracted",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_signals_postedAt",
+ "unique": false,
+ "columnNames": [
+ "postedAt"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_signals_postedAt` ON `${TABLE_NAME}` (`postedAt`)"
+ },
+ {
+ "name": "index_signals_appPackage",
+ "unique": false,
+ "columnNames": [
+ "appPackage"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_signals_appPackage` ON `${TABLE_NAME}` (`appPackage`)"
+ }
+ ]
+ },
+ {
+ "tableName": "recall_chunks",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceKey` TEXT NOT NULL, `module` TEXT NOT NULL, `title` TEXT NOT NULL, `body` TEXT NOT NULL, `vector` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceKey",
+ "columnName": "sourceKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "module",
+ "columnName": "module",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "body",
+ "columnName": "body",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "vector",
+ "columnName": "vector",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_recall_chunks_sourceKey",
+ "unique": false,
+ "columnNames": [
+ "sourceKey"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_recall_chunks_sourceKey` ON `${TABLE_NAME}` (`sourceKey`)"
+ },
+ {
+ "name": "index_recall_chunks_module",
+ "unique": false,
+ "columnNames": [
+ "module"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_recall_chunks_module` ON `${TABLE_NAME}` (`module`)"
+ }
+ ]
+ },
+ {
+ "tableName": "backup_runs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `at` INTEGER NOT NULL, `destination` TEXT NOT NULL, `fileName` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `status` TEXT NOT NULL, `detail` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "at",
+ "columnName": "at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "destination",
+ "columnName": "destination",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fileName",
+ "columnName": "fileName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sizeBytes",
+ "columnName": "sizeBytes",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "detail",
+ "columnName": "detail",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ }
+ ],
+ "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, 'cf4620bb9fca2d2a552007faa7ff59c1')"
+ ]
+ }
+}
\ 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 4f8d222..052c3a9 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
@@ -104,8 +104,14 @@ import com.lifeos.core.database.screentime.ScreenTimeDayEntity
BrickAppLimitEntity::class,
BrickSessionEntity::class,
BrickUsageEntity::class,
+ com.lifeos.core.database.places.PlaceEntity::class,
+ com.lifeos.core.database.triggers.TriggerRuleEntity::class,
+ com.lifeos.core.database.triggers.TriggerFireEntity::class,
+ com.lifeos.core.database.signals.SignalEntity::class,
+ com.lifeos.core.database.recall.RecallChunkEntity::class,
+ com.lifeos.core.database.backup.BackupRunEntity::class,
],
- version = 16,
+ version = 17,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 1, to = 2),
@@ -123,6 +129,7 @@ import com.lifeos.core.database.screentime.ScreenTimeDayEntity
AutoMigration(from = 13, to = 14),
AutoMigration(from = 14, to = 15),
AutoMigration(from = 15, to = 16),
+ AutoMigration(from = 16, to = 17),
],
)
abstract class LifeDatabase : RoomDatabase() {
@@ -147,6 +154,16 @@ abstract class LifeDatabase : RoomDatabase() {
abstract fun downloadDao(): DownloadDao
abstract fun plantDao(): PlantDao
abstract fun screenTimeDao(): ScreenTimeDao
+ abstract fun placeDao(): com.lifeos.core.database.places.PlaceDao
+
+ abstract fun triggerDao(): com.lifeos.core.database.triggers.TriggerDao
+
+ abstract fun signalDao(): com.lifeos.core.database.signals.SignalDao
+
+ abstract fun recallDao(): com.lifeos.core.database.recall.RecallDao
+
+ abstract fun backupDao(): com.lifeos.core.database.backup.BackupDao
+
abstract fun brickDao(): BrickDao
companion object {
diff --git a/core/database/src/main/kotlin/com/lifeos/core/database/backup/BackupEntities.kt b/core/database/src/main/kotlin/com/lifeos/core/database/backup/BackupEntities.kt
new file mode 100644
index 0000000..4bfc03b
--- /dev/null
+++ b/core/database/src/main/kotlin/com/lifeos/core/database/backup/BackupEntities.kt
@@ -0,0 +1,37 @@
+package com.lifeos.core.database.backup
+
+import androidx.room.Dao
+import androidx.room.Entity
+import androidx.room.Insert
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+/** One backup attempt (§Module Sync): what was written, where, and whether it verified. */
+@Entity(tableName = "backup_runs")
+data class BackupRunEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val at: Long,
+ val destination: String,
+ val fileName: String,
+ val sizeBytes: Long,
+ /** OK, FAILED, VERIFIED */
+ val status: String,
+ val detail: String = "",
+)
+
+@Dao
+interface BackupDao {
+
+ @Insert
+ suspend fun insert(run: BackupRunEntity): Long
+
+ @Query("SELECT * FROM backup_runs ORDER BY at DESC LIMIT 30")
+ fun observeRuns(): Flow>
+
+ @Query("SELECT * FROM backup_runs ORDER BY at DESC LIMIT 1")
+ suspend fun latest(): BackupRunEntity?
+
+ @Query("SELECT * FROM backup_runs ORDER BY at DESC LIMIT :limit")
+ suspend fun recent(limit: Int): List
+}
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 9220faa..0a9bc55 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
@@ -107,4 +107,24 @@ internal object DatabaseModule {
@Provides
fun provideBrickDao(database: LifeDatabase): BrickDao = database.brickDao()
+
+ @Provides
+ fun providePlaceDao(database: LifeDatabase): com.lifeos.core.database.places.PlaceDao =
+ database.placeDao()
+
+ @Provides
+ fun provideTriggerDao(database: LifeDatabase): com.lifeos.core.database.triggers.TriggerDao =
+ database.triggerDao()
+
+ @Provides
+ fun provideSignalDao(database: LifeDatabase): com.lifeos.core.database.signals.SignalDao =
+ database.signalDao()
+
+ @Provides
+ fun provideRecallDao(database: LifeDatabase): com.lifeos.core.database.recall.RecallDao =
+ database.recallDao()
+
+ @Provides
+ fun provideBackupDao(database: LifeDatabase): com.lifeos.core.database.backup.BackupDao =
+ database.backupDao()
}
diff --git a/core/database/src/main/kotlin/com/lifeos/core/database/places/PlaceEntities.kt b/core/database/src/main/kotlin/com/lifeos/core/database/places/PlaceEntities.kt
new file mode 100644
index 0000000..c3cfcc7
--- /dev/null
+++ b/core/database/src/main/kotlin/com/lifeos/core/database/places/PlaceEntities.kt
@@ -0,0 +1,48 @@
+package com.lifeos.core.database.places
+
+import androidx.room.Dao
+import androidx.room.Entity
+import androidx.room.Insert
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * A place LifeOS recognises (§Module Places).
+ *
+ * Without Play Services there is no system geofence, so a place is matched by
+ * whichever signals it has: coordinates with a radius, the Wi-Fi network it
+ * belongs to, or both. Wi-Fi alone is the cheapest and the most reliable indoors.
+ */
+@Entity(tableName = "places")
+data class PlaceEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val name: String,
+ val latitude: Double? = null,
+ val longitude: Double? = null,
+ /** Match radius in metres; ignored when there are no coordinates. */
+ val radiusMeters: Int = 150,
+ /** Wi-Fi network name that means "here"; matched case-insensitively. */
+ val wifiSsid: String? = null,
+ val createdAt: Long,
+)
+
+@Dao
+interface PlaceDao {
+
+ @Query("SELECT * FROM places ORDER BY name")
+ fun observeAll(): Flow>
+
+ @Query("SELECT * FROM places ORDER BY name")
+ suspend fun all(): List
+
+ @Insert
+ suspend fun insert(place: PlaceEntity): Long
+
+ @Update
+ suspend fun update(place: PlaceEntity)
+
+ @Query("DELETE FROM places WHERE id = :id")
+ suspend fun delete(id: Long)
+}
diff --git a/core/database/src/main/kotlin/com/lifeos/core/database/recall/RecallEntities.kt b/core/database/src/main/kotlin/com/lifeos/core/database/recall/RecallEntities.kt
new file mode 100644
index 0000000..6d37903
--- /dev/null
+++ b/core/database/src/main/kotlin/com/lifeos/core/database/recall/RecallEntities.kt
@@ -0,0 +1,51 @@
+package com.lifeos.core.database.recall
+
+import androidx.room.Dao
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.PrimaryKey
+import androidx.room.Query
+
+/**
+ * One embedded chunk of the user's own text (§Module Recall).
+ *
+ * [sourceKey] is "module:id" so re-indexing a changed note replaces its chunks
+ * instead of piling up duplicates. Vectors are stored as a comma-separated
+ * string: small enough for a phone-sized corpus and it keeps Room schema-simple.
+ */
+@Entity(tableName = "recall_chunks", indices = [Index("sourceKey"), Index("module")])
+data class RecallChunkEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val sourceKey: String,
+ val module: String,
+ val title: String,
+ val body: String,
+ val vector: String,
+ val updatedAt: Long,
+)
+
+@Dao
+interface RecallDao {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAll(chunks: List)
+
+ @Query("DELETE FROM recall_chunks WHERE sourceKey = :sourceKey")
+ suspend fun deleteSource(sourceKey: String)
+
+ @Query("SELECT * FROM recall_chunks")
+ suspend fun all(): List
+
+ @Query("SELECT sourceKey, updatedAt FROM recall_chunks GROUP BY sourceKey")
+ suspend fun indexedSources(): List
+
+ @Query("SELECT COUNT(*) FROM recall_chunks")
+ suspend fun count(): Int
+
+ @Query("DELETE FROM recall_chunks")
+ suspend fun clear()
+}
+
+data class IndexedSource(val sourceKey: String, val updatedAt: Long)
diff --git a/core/database/src/main/kotlin/com/lifeos/core/database/signals/SignalEntities.kt b/core/database/src/main/kotlin/com/lifeos/core/database/signals/SignalEntities.kt
new file mode 100644
index 0000000..9b27a27
--- /dev/null
+++ b/core/database/src/main/kotlin/com/lifeos/core/database/signals/SignalEntities.kt
@@ -0,0 +1,49 @@
+package com.lifeos.core.database.signals
+
+import androidx.room.Dao
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.Insert
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+/** One captured notification (§Module Signals). */
+@Entity(tableName = "signals", indices = [Index("postedAt"), Index("appPackage")])
+data class SignalEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val appPackage: String,
+ val appLabel: String,
+ val title: String,
+ val text: String,
+ val postedAt: Long,
+ /** Set when the user has seen it in the digest. */
+ val readAt: Long? = null,
+ /** What LifeOS did with it: NONE, PARCEL, CODE, RECEIPT. */
+ val extracted: String = "NONE",
+)
+
+@Dao
+interface SignalDao {
+
+ @Insert
+ suspend fun insert(signal: SignalEntity): Long
+
+ @Query("SELECT * FROM signals ORDER BY postedAt DESC LIMIT 200")
+ fun observeRecent(): Flow>
+
+ @Query("SELECT * FROM signals WHERE postedAt >= :since ORDER BY postedAt DESC")
+ suspend fun since(since: Long): List
+
+ @Query("SELECT * FROM signals WHERE readAt IS NULL ORDER BY postedAt DESC LIMIT :limit")
+ suspend fun unread(limit: Int): List
+
+ @Query("UPDATE signals SET readAt = :at WHERE readAt IS NULL")
+ suspend fun markAllRead(at: Long)
+
+ @Query("DELETE FROM signals WHERE postedAt < :before")
+ suspend fun trim(before: Long)
+
+ @Query("DELETE FROM signals")
+ suspend fun clear()
+}
diff --git a/core/database/src/main/kotlin/com/lifeos/core/database/triggers/TriggerEntities.kt b/core/database/src/main/kotlin/com/lifeos/core/database/triggers/TriggerEntities.kt
new file mode 100644
index 0000000..0dc1f63
--- /dev/null
+++ b/core/database/src/main/kotlin/com/lifeos/core/database/triggers/TriggerEntities.kt
@@ -0,0 +1,87 @@
+package com.lifeos.core.database.triggers
+
+import androidx.room.Dao
+import androidx.room.Entity
+import androidx.room.Insert
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * One automation rule (§Module Triggers): when something happens, and the
+ * conditions hold, do something.
+ *
+ * Trigger and action arguments are stored as plain strings rather than a schema
+ * per type: the rule engine owns their meaning, and a rule that cannot be parsed
+ * is shown as broken instead of silently doing nothing.
+ */
+@Entity(tableName = "trigger_rules")
+data class TriggerRuleEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val name: String,
+ /** TIME, PLACE_ENTER, PLACE_LEAVE, WIFI, NFC_TAG, SIGNAL, SCREEN_TIME, BATTERY, REMINDER_FIRED. */
+ val triggerType: String,
+ /** Minute of day, place id, SSID, tag id, package or keyword, threshold. */
+ val triggerArg: String = "",
+ /** Comma-separated day numbers (1=Mon..7=Sun); empty = every day. */
+ val days: String = "",
+ /** Only fire inside this window, as "start-end" minutes of day; empty = always. */
+ val window: String = "",
+ /** TASK, NOTE, REMINDER, TIMER, FOCUS, BRICK_ON, BRICK_OFF, MACRO, PASTE, DOWNLOAD, SCREEN_TIME_EXPORT, WATER_PLANT. */
+ val actionType: String,
+ val actionArg: String = "",
+ val enabled: Boolean = true,
+ /** Rules can be run by hand from the list, which is also the dry run. */
+ val lastFiredAt: Long? = null,
+ val fireCount: Int = 0,
+ val createdAt: Long,
+)
+
+/** Audit trail: what fired, when, and whether the action worked. */
+@Entity(tableName = "trigger_fires")
+data class TriggerFireEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val ruleId: Long,
+ val ruleName: String,
+ val at: Long,
+ val outcome: String,
+ val detail: String = "",
+)
+
+@Dao
+interface TriggerDao {
+
+ @Query("SELECT * FROM trigger_rules ORDER BY name")
+ fun observeRules(): Flow>
+
+ @Query("SELECT * FROM trigger_rules ORDER BY name")
+ suspend fun allRules(): List
+
+ @Query("SELECT * FROM trigger_rules WHERE enabled = 1")
+ suspend fun enabledRules(): List
+
+ @Query("SELECT * FROM trigger_rules WHERE id = :id")
+ suspend fun rule(id: Long): TriggerRuleEntity?
+
+ @Insert
+ suspend fun insertRule(rule: TriggerRuleEntity): Long
+
+ @Update
+ suspend fun updateRule(rule: TriggerRuleEntity)
+
+ @Query("DELETE FROM trigger_rules WHERE id = :id")
+ suspend fun deleteRule(id: Long)
+
+ @Insert
+ suspend fun insertFire(fire: TriggerFireEntity): Long
+
+ @Query("SELECT * FROM trigger_fires ORDER BY at DESC LIMIT 60")
+ fun observeFires(): Flow>
+
+ @Query("SELECT * FROM trigger_fires ORDER BY at DESC LIMIT :limit")
+ suspend fun recentFires(limit: Int): List
+
+ @Query("DELETE FROM trigger_fires WHERE at < :before")
+ suspend fun trimFires(before: Long)
+}
diff --git a/core/datastore/src/main/kotlin/com/lifeos/core/datastore/DataStoreSettingsRepository.kt b/core/datastore/src/main/kotlin/com/lifeos/core/datastore/DataStoreSettingsRepository.kt
index a975af1..765f2df 100644
--- a/core/datastore/src/main/kotlin/com/lifeos/core/datastore/DataStoreSettingsRepository.kt
+++ b/core/datastore/src/main/kotlin/com/lifeos/core/datastore/DataStoreSettingsRepository.kt
@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -125,6 +126,41 @@ internal class DataStoreSettingsRepository @Inject constructor(
dataStore.edit { prefs -> prefs[KEY_PRIVATEBIN_INSTANCE] = url.trim() }
}
+ override val backupPassphrase: Flow =
+ dataStore.data.map { prefs -> prefs[KEY_BACKUP_PASSPHRASE] ?: "" }
+
+ override suspend fun setBackupPassphrase(value: String) {
+ dataStore.edit { prefs -> prefs[KEY_BACKUP_PASSPHRASE] = value }
+ }
+
+ override val backupWebdavUrl: Flow =
+ dataStore.data.map { prefs -> prefs[KEY_BACKUP_WEBDAV_URL] ?: "" }
+
+ override suspend fun setBackupWebdavUrl(value: String) {
+ dataStore.edit { prefs -> prefs[KEY_BACKUP_WEBDAV_URL] = value.trim() }
+ }
+
+ override val backupWebdavUser: Flow =
+ dataStore.data.map { prefs -> prefs[KEY_BACKUP_WEBDAV_USER] ?: "" }
+
+ override suspend fun setBackupWebdavUser(value: String) {
+ dataStore.edit { prefs -> prefs[KEY_BACKUP_WEBDAV_USER] = value.trim() }
+ }
+
+ override val backupWebdavPassword: Flow =
+ dataStore.data.map { prefs -> prefs[KEY_BACKUP_WEBDAV_PASSWORD] ?: "" }
+
+ override suspend fun setBackupWebdavPassword(value: String) {
+ dataStore.edit { prefs -> prefs[KEY_BACKUP_WEBDAV_PASSWORD] = value }
+ }
+
+ override val backupKeepGenerations: Flow =
+ dataStore.data.map { prefs -> prefs[KEY_BACKUP_KEEP] ?: 5 }
+
+ override suspend fun setBackupKeepGenerations(value: Int) {
+ dataStore.edit { prefs -> prefs[KEY_BACKUP_KEEP] = value.coerceIn(1, 30) }
+ }
+
override val clearSkyPlaces: Flow =
dataStore.data.map { prefs -> prefs[KEY_CLEAR_SKY_PLACES] ?: "" }
@@ -162,6 +198,11 @@ internal class DataStoreSettingsRepository @Inject constructor(
val KEY_PASTEBIN_SHARE_DEFAULTS = stringPreferencesKey("pastebin_share_defaults")
val KEY_PASTEBIN_USER_KEY = stringPreferencesKey("pastebin_user_key")
val KEY_PRIVATEBIN_INSTANCE = stringPreferencesKey("privatebin_instance")
+ val KEY_BACKUP_PASSPHRASE = stringPreferencesKey("backup_passphrase")
+ val KEY_BACKUP_WEBDAV_URL = stringPreferencesKey("backup_webdav_url")
+ val KEY_BACKUP_WEBDAV_USER = stringPreferencesKey("backup_webdav_user")
+ val KEY_BACKUP_WEBDAV_PASSWORD = stringPreferencesKey("backup_webdav_password")
+ val KEY_BACKUP_KEEP = intPreferencesKey("backup_keep_generations")
val KEY_CLEAR_SKY_PLACES = stringPreferencesKey("clear_sky_places")
val KEY_CLEAR_SKY_LAST_PLACE = stringPreferencesKey("clear_sky_last_place")
}
diff --git a/core/datastore/src/main/kotlin/com/lifeos/core/datastore/SettingsRepository.kt b/core/datastore/src/main/kotlin/com/lifeos/core/datastore/SettingsRepository.kt
index 255db7c..8697810 100644
--- a/core/datastore/src/main/kotlin/com/lifeos/core/datastore/SettingsRepository.kt
+++ b/core/datastore/src/main/kotlin/com/lifeos/core/datastore/SettingsRepository.kt
@@ -85,6 +85,29 @@ interface SettingsRepository {
suspend fun setPrivateBinInstance(url: String)
+ /** Passphrase the encrypted database backups are derived from. */
+ val backupPassphrase: Flow
+
+ suspend fun setBackupPassphrase(value: String)
+
+ /** WebDAV collection snapshots are pushed to; empty = local only. */
+ val backupWebdavUrl: Flow
+
+ suspend fun setBackupWebdavUrl(value: String)
+
+ val backupWebdavUser: Flow
+
+ suspend fun setBackupWebdavUser(value: String)
+
+ val backupWebdavPassword: Flow
+
+ suspend fun setBackupWebdavPassword(value: String)
+
+ /** How many local snapshots to keep. */
+ val backupKeepGenerations: Flow
+
+ suspend fun setBackupKeepGenerations(value: Int)
+
/** Saved Clear Sky observing spots, one "name~lat~lon" per line. */
val clearSkyPlaces: Flow
diff --git a/core/places/build.gradle.kts b/core/places/build.gradle.kts
new file mode 100644
index 0000000..1ec2350
--- /dev/null
+++ b/core/places/build.gradle.kts
@@ -0,0 +1,12 @@
+plugins {
+ alias(libs.plugins.lifeos.android.library)
+ alias(libs.plugins.lifeos.hilt)
+}
+
+dependencies {
+ implementation(projects.core.common)
+ implementation(projects.core.database)
+ implementation(projects.core.service)
+
+ implementation(libs.androidx.core.ktx)
+}
diff --git a/core/places/src/main/AndroidManifest.xml b/core/places/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..fe4813d
--- /dev/null
+++ b/core/places/src/main/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/core/places/src/main/kotlin/com/lifeos/core/places/PlaceEngine.kt b/core/places/src/main/kotlin/com/lifeos/core/places/PlaceEngine.kt
new file mode 100644
index 0000000..f49d09c
--- /dev/null
+++ b/core/places/src/main/kotlin/com/lifeos/core/places/PlaceEngine.kt
@@ -0,0 +1,150 @@
+package com.lifeos.core.places
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.location.Location
+import android.location.LocationManager
+import android.net.wifi.WifiManager
+import androidx.core.content.ContextCompat
+import com.lifeos.core.common.log.LifeLogger
+import com.lifeos.core.database.places.PlaceDao
+import com.lifeos.core.database.places.PlaceEntity
+import com.lifeos.core.service.LifeEvent
+import com.lifeos.core.service.LifeEventBus
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Where the phone is, in terms the user named (§Module Places).
+ *
+ * Android gives no geofence API outside Play Services, so this polls cheaply
+ * instead: the Wi-Fi network it is joined to (free, instant, reliable indoors)
+ * plus the last known location from the OS providers (no active GPS fix is ever
+ * requested). Enter and leave are published on [LifeEventBus], which is what the
+ * Triggers engine listens to.
+ */
+@Singleton
+class PlaceEngine @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val placeDao: PlaceDao,
+ private val eventBus: LifeEventBus,
+) {
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
+ private val _current = MutableStateFlow(null)
+ val current = _current.asStateFlow()
+
+ private var started = false
+
+ /** Starts the poll loop once; safe to call from every process entry point. */
+ fun start() {
+ if (started) return
+ started = true
+ scope.launch {
+ while (true) {
+ runCatching { evaluate() }
+ .onFailure { LifeLogger.w(TAG, "Place evaluation failed", it) }
+ delay(POLL_MS)
+ }
+ }
+ }
+
+ /** Re-checks immediately; used after saving a place and by the rules engine. */
+ suspend fun refresh() = evaluate()
+
+ private suspend fun evaluate() {
+ val places = placeDao.all()
+ if (places.isEmpty()) {
+ transitionTo(null)
+ return
+ }
+ val ssid = currentSsid()
+ val byWifi = ssid?.let { network ->
+ places.firstOrNull { it.wifiSsid?.trim()?.equals(network, ignoreCase = true) == true }
+ }
+ if (byWifi != null) {
+ transitionTo(byWifi)
+ return
+ }
+ val fix = lastKnownLocation()
+ if (fix == null) {
+ // No signal either way: keep the last verdict rather than inventing
+ // a "left" event the user would feel as a false trigger.
+ return
+ }
+ val byDistance = places
+ .filter { it.latitude != null && it.longitude != null }
+ .map { place -> place to distanceMeters(fix, place) }
+ .filter { (place, distance) -> distance <= place.radiusMeters }
+ .minByOrNull { it.second }
+ ?.first
+ transitionTo(byDistance)
+ }
+
+ private suspend fun transitionTo(place: PlaceEntity?) {
+ val previous = _current.value
+ if (previous?.id == place?.id) return
+ _current.value = place
+ previous?.let { eventBus.publish(LifeEvent.PlaceLeft(it.id, it.name)) }
+ place?.let { eventBus.publish(LifeEvent.PlaceEntered(it.id, it.name)) }
+ }
+
+ private fun distanceMeters(fix: Location, place: PlaceEntity): Float {
+ val result = FloatArray(1)
+ Location.distanceBetween(
+ fix.latitude,
+ fix.longitude,
+ place.latitude ?: return Float.MAX_VALUE,
+ place.longitude ?: return Float.MAX_VALUE,
+ result,
+ )
+ return result[0]
+ }
+
+ /** Joined network name, or null when Wi-Fi is off or the name is hidden. */
+ fun currentSsid(): String? {
+ if (!granted(Manifest.permission.ACCESS_FINE_LOCATION) &&
+ !granted(Manifest.permission.ACCESS_COARSE_LOCATION)
+ ) {
+ return null
+ }
+ val manager = context.getSystemService(WifiManager::class.java) ?: return null
+ @Suppress("DEPRECATION")
+ val raw = runCatching { manager.connectionInfo?.ssid }.getOrNull() ?: return null
+ val cleaned = raw.trim('"')
+ return cleaned.takeIf { it.isNotBlank() && it != "" }
+ }
+
+ fun lastKnownLocation(): Location? {
+ if (!granted(Manifest.permission.ACCESS_COARSE_LOCATION)) return null
+ val manager = context.getSystemService(LocationManager::class.java) ?: return null
+ return listOf(
+ LocationManager.GPS_PROVIDER,
+ LocationManager.NETWORK_PROVIDER,
+ LocationManager.PASSIVE_PROVIDER,
+ )
+ .mapNotNull { provider -> runCatching { manager.getLastKnownLocation(provider) }.getOrNull() }
+ .maxByOrNull { it.time }
+ }
+
+ fun hasLocationPermission(): Boolean = granted(Manifest.permission.ACCESS_COARSE_LOCATION)
+
+ private fun granted(permission: String): Boolean =
+ ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
+
+ private companion object {
+ const val TAG = "PlaceEngine"
+ /** Two minutes: coarse enough to be invisible on battery, quick enough to feel live. */
+ const val POLL_MS = 120_000L
+ }
+}
diff --git a/core/places/src/main/kotlin/com/lifeos/core/places/PlacesJarvisBridge.kt b/core/places/src/main/kotlin/com/lifeos/core/places/PlacesJarvisBridge.kt
new file mode 100644
index 0000000..9a5ce74
--- /dev/null
+++ b/core/places/src/main/kotlin/com/lifeos/core/places/PlacesJarvisBridge.kt
@@ -0,0 +1,57 @@
+package com.lifeos.core.places
+
+import com.lifeos.core.database.places.PlaceDao
+import com.lifeos.core.database.places.PlaceEntity
+import com.lifeos.core.service.LifeDataProvider
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
+import javax.inject.Inject
+
+/** Places as Jarvis reads them: where you are and which spots are known. */
+internal class PlacesProvider @Inject constructor(
+ private val placeDao: PlaceDao,
+ private val engine: PlaceEngine,
+) : LifeDataProvider {
+
+ override val topic: String = "places"
+ override val description: String = "saved places and where the phone is right now"
+
+ override suspend fun read(query: String?): String {
+ val places = placeDao.all()
+ val here = engine.current.value
+ return buildString {
+ appendLine(
+ when {
+ here != null -> "You are at ${here.name}."
+ !engine.hasLocationPermission() -> "Location permission is not granted, so place matching is off."
+ else -> "Not at any saved place right now."
+ },
+ )
+ engine.currentSsid()?.let { appendLine("Wi-Fi: $it") }
+ if (places.isEmpty()) {
+ appendLine("No places saved yet.")
+ } else {
+ appendLine("Saved places:")
+ places.forEach { appendLine("- ${it.describe()}") }
+ }
+ }.trim()
+ }
+
+ private fun PlaceEntity.describe(): String = buildString {
+ append(name)
+ if (latitude != null && longitude != null) append(" (${latitude}, ${longitude}, ${radiusMeters}m)")
+ wifiSsid?.let { append(" wifi:$it") }
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal abstract class PlacesJarvisModule {
+
+ @Binds
+ @IntoSet
+ abstract fun bindProvider(impl: PlacesProvider): LifeDataProvider
+}
diff --git a/core/recall/build.gradle.kts b/core/recall/build.gradle.kts
new file mode 100644
index 0000000..a605202
--- /dev/null
+++ b/core/recall/build.gradle.kts
@@ -0,0 +1,14 @@
+plugins {
+ alias(libs.plugins.lifeos.android.library)
+ alias(libs.plugins.lifeos.hilt)
+}
+
+dependencies {
+ implementation(projects.core.common)
+ implementation(projects.core.ai)
+ implementation(projects.core.database)
+ implementation(projects.core.service)
+
+ implementation(libs.androidx.core.ktx)
+ testImplementation(libs.junit)
+}
diff --git a/core/recall/src/main/AndroidManifest.xml b/core/recall/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..b2d3ea1
--- /dev/null
+++ b/core/recall/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/core/recall/src/main/kotlin/com/lifeos/core/recall/RecallIndex.kt b/core/recall/src/main/kotlin/com/lifeos/core/recall/RecallIndex.kt
new file mode 100644
index 0000000..1e6ae5e
--- /dev/null
+++ b/core/recall/src/main/kotlin/com/lifeos/core/recall/RecallIndex.kt
@@ -0,0 +1,177 @@
+package com.lifeos.core.recall
+
+import com.lifeos.core.ai.rag.TextEmbedder
+import com.lifeos.core.common.log.LifeLogger
+import com.lifeos.core.database.capture.CaptureDao
+import com.lifeos.core.database.chat.ChatDao
+import com.lifeos.core.database.memex.MemexDao
+import com.lifeos.core.database.notes.NoteDao
+import com.lifeos.core.database.recall.RecallChunkEntity
+import com.lifeos.core.database.recall.RecallDao
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** One hit, with enough provenance for Jarvis to cite it. */
+data class RecallHit(
+ val module: String,
+ val title: String,
+ val text: String,
+ val score: Float,
+)
+
+/**
+ * Semantic memory over everything the user wrote (§Module Recall).
+ *
+ * Notes, captures, memex clips, chat history and the readable files under
+ * /LifeOS are chunked, embedded on-device and stored in Room. Indexing is
+ * incremental - a source whose timestamp has not moved is skipped - so the
+ * usual run costs almost nothing, and retrieval is a cosine scan, which is
+ * plenty at personal-corpus scale.
+ */
+@Singleton
+class RecallIndex @Inject constructor(
+ private val recallDao: RecallDao,
+ private val embedder: TextEmbedder,
+ private val noteDao: NoteDao,
+ private val captureDao: CaptureDao,
+ private val memexDao: MemexDao,
+ private val chatDao: ChatDao,
+) {
+
+ private val mutex = Mutex()
+
+ /** Adds or replaces one source's chunks. Safe to call from any module. */
+ suspend fun put(module: String, sourceId: String, title: String, body: String, updatedAt: Long) {
+ val text = body.trim()
+ if (text.isBlank()) return
+ val key = "$module:$sourceId"
+ recallDao.deleteSource(key)
+ val chunks = chunk(text).mapIndexed { index, piece ->
+ RecallChunkEntity(
+ sourceKey = key,
+ module = module,
+ title = title.take(120).ifBlank { module },
+ body = piece,
+ vector = embedder.embed("$title\n$piece").joinToString(",") { it.toString() },
+ updatedAt = updatedAt,
+ )
+ }
+ if (chunks.isNotEmpty()) recallDao.insertAll(chunks)
+ }
+
+ /**
+ * Walks every indexable source. [force] re-embeds even unchanged rows,
+ * which is what the "rebuild" button does after the embedder changes.
+ */
+ suspend fun reindex(force: Boolean = false): Int = mutex.withLock {
+ withContext(Dispatchers.IO) {
+ if (force) recallDao.clear()
+ val known = recallDao.indexedSources().associate { it.sourceKey to it.updatedAt }
+ var indexed = 0
+
+ noteDao.observeAll().first().forEach { note ->
+ if (note.bodyVaultRef != null) return@forEach
+ val key = "note:${note.id}"
+ if (!force && known[key] == note.updatedAt) return@forEach
+ val body = runCatching { File(note.path).takeIf { it.exists() }?.readText() }.getOrNull().orEmpty()
+ put("note", note.id.toString(), note.title, body, note.updatedAt)
+ indexed++
+ }
+
+ captureDao.observeRecent().first().forEach { capture ->
+ val text = capture.text ?: return@forEach
+ val key = "capture:${capture.id}"
+ if (!force && known[key] == capture.createdAt) return@forEach
+ put("capture", capture.id.toString(), text.take(60), text, capture.createdAt)
+ indexed++
+ }
+
+ memexDao.observeAll().first().forEach { clip ->
+ val key = "memex:${clip.id}"
+ if (!force && known[key] == clip.capturedAt) return@forEach
+ put("memex", clip.id.toString(), clip.title, clip.body, clip.capturedAt)
+ indexed++
+ }
+
+ chatDao.observeConversations().first().take(40).forEach { conversation ->
+ val key = "chat:${conversation.id}"
+ if (!force && known[key] == conversation.updatedAt) return@forEach
+ val body = chatDao.getMessages(conversation.id)
+ .joinToString("\n") { "${it.role}: ${it.content}" }
+ put("chat", conversation.id.toString(), conversation.title, body, conversation.updatedAt)
+ indexed++
+ }
+
+ indexFolder(known, force)?.let { indexed += it }
+ LifeLogger.i(TAG, "Recall indexed $indexed source(s), ${recallDao.count()} chunks")
+ indexed
+ }
+ }
+
+ /** The readable mirror under /Internal storage/LifeOS, when access was granted. */
+ private suspend fun indexFolder(known: Map, force: Boolean): Int? {
+ val root = File(android.os.Environment.getExternalStorageDirectory(), "LifeOS")
+ if (!root.exists() || !root.canRead()) return null
+ var count = 0
+ root.walkTopDown()
+ .maxDepth(3)
+ .filter { it.isFile && it.length() in 1..MAX_FILE_BYTES && it.extension.lowercase() in TEXT_EXTENSIONS }
+ .take(300)
+ .forEach { file ->
+ val key = "file:${file.absolutePath}"
+ if (!force && known[key] == file.lastModified()) return@forEach
+ val body = runCatching { file.readText() }.getOrNull() ?: return@forEach
+ put("file", file.absolutePath, file.name, body, file.lastModified())
+ count++
+ }
+ return count
+ }
+
+ /** Top matches by cosine similarity. */
+ suspend fun search(query: String, limit: Int = 6): List = withContext(Dispatchers.Default) {
+ if (query.isBlank()) return@withContext emptyList()
+ val target = embedder.embed(query)
+ recallDao.all()
+ .mapNotNull { chunk ->
+ val vector = chunk.vector.split(',').mapNotNull { it.toFloatOrNull() }
+ if (vector.size != target.size) return@mapNotNull null
+ var dot = 0f
+ for (i in target.indices) dot += target[i] * vector[i]
+ if (dot <= MIN_SCORE) null else RecallHit(chunk.module, chunk.title, chunk.body, dot)
+ }
+ .sortedByDescending { it.score }
+ .take(limit)
+ }
+
+ suspend fun size(): Int = recallDao.count()
+
+ /** Paragraph-ish chunks, bounded so one huge note cannot dominate the index. */
+ private fun chunk(text: String): List {
+ val paragraphs = text.split(Regex("\n{2,}")).map { it.trim() }.filter { it.isNotEmpty() }
+ val chunks = mutableListOf()
+ paragraphs.forEach { paragraph ->
+ val current = chunks.lastOrNull()
+ if (current != null && current.length + paragraph.length < CHUNK_CHARS) {
+ current.append("\n\n").append(paragraph)
+ } else {
+ chunks += StringBuilder(paragraph.take(CHUNK_CHARS))
+ }
+ }
+ return chunks.map { it.toString() }.take(MAX_CHUNKS_PER_SOURCE)
+ }
+
+ private companion object {
+ const val TAG = "RecallIndex"
+ const val CHUNK_CHARS = 700
+ const val MAX_CHUNKS_PER_SOURCE = 24
+ const val MAX_FILE_BYTES = 512L * 1024
+ const val MIN_SCORE = 0.05f
+ val TEXT_EXTENSIONS = setOf("md", "txt", "json", "csv", "log")
+ }
+}
diff --git a/core/recall/src/main/kotlin/com/lifeos/core/recall/RecallJarvisBridge.kt b/core/recall/src/main/kotlin/com/lifeos/core/recall/RecallJarvisBridge.kt
new file mode 100644
index 0000000..8a85cf0
--- /dev/null
+++ b/core/recall/src/main/kotlin/com/lifeos/core/recall/RecallJarvisBridge.kt
@@ -0,0 +1,64 @@
+package com.lifeos.core.recall
+
+import com.lifeos.core.common.result.LifeResult
+import com.lifeos.core.service.LifeAction
+import com.lifeos.core.service.LifeActionHandler
+import com.lifeos.core.service.LifeDataProvider
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
+import javax.inject.Inject
+
+/**
+ * Semantic search as Jarvis reads it (§Module Recall). This is the topic he
+ * should reach for when a question is about something the user wrote but does
+ * not name exactly - keyword search is what fails there.
+ */
+internal class RecallProvider @Inject constructor(
+ private val index: RecallIndex,
+) : LifeDataProvider {
+
+ override val topic: String = "recall"
+ override val description: String = "semantic search over notes, clips, captures, chats and /LifeOS files"
+
+ override suspend fun read(query: String?): String {
+ if (query.isNullOrBlank()) {
+ return "Recall holds ${index.size()} chunks. Ask with a query: [[get: recall | what you remember]]"
+ }
+ val hits = index.search(query)
+ if (hits.isEmpty()) return "Recall found nothing for \"$query\" in ${index.size()} chunks."
+ return buildString {
+ appendLine("Recall for \"$query\":")
+ hits.forEach { hit ->
+ appendLine("- [${hit.module}] ${hit.title}: ${hit.text.replace('\n', ' ').take(240)}")
+ }
+ }.trim()
+ }
+}
+
+internal class RecallActionHandler @Inject constructor(
+ private val index: RecallIndex,
+) : LifeActionHandler {
+
+ override fun canHandle(action: LifeAction): Boolean = action is LifeAction.ReindexRecall
+
+ override suspend fun execute(action: LifeAction): LifeResult {
+ val indexed = index.reindex(force = false)
+ return LifeResult.Success(indexed.toLong())
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal abstract class RecallJarvisModule {
+
+ @Binds
+ @IntoSet
+ abstract fun bindProvider(impl: RecallProvider): LifeDataProvider
+
+ @Binds
+ @IntoSet
+ abstract fun bindHandler(impl: RecallActionHandler): LifeActionHandler
+}
diff --git a/core/service/src/main/kotlin/com/lifeos/core/service/LifeAction.kt b/core/service/src/main/kotlin/com/lifeos/core/service/LifeAction.kt
index 315032f..8590488 100644
--- a/core/service/src/main/kotlin/com/lifeos/core/service/LifeAction.kt
+++ b/core/service/src/main/kotlin/com/lifeos/core/service/LifeAction.kt
@@ -134,4 +134,49 @@ sealed interface LifeAction {
val macroName: String,
override val source: SourceRef,
) : LifeAction
+
+ /** Writes an automation rule (§Module Triggers). */
+ data class CreateTriggerRule(
+ val name: String,
+ val triggerType: String,
+ val triggerArg: String,
+ val actionType: String,
+ val actionArg: String,
+ /** Comma-separated 1=Monday..7=Sunday; empty = every day. */
+ val days: String,
+ override val source: SourceRef,
+ ) : LifeAction
+
+ data class SetTriggerRuleEnabled(
+ val ruleName: String,
+ val enabled: Boolean,
+ override val source: SourceRef,
+ ) : LifeAction
+
+ data class RunTriggerRule(
+ val ruleName: String,
+ override val source: SourceRef,
+ ) : LifeAction
+
+ /** Backs the database up to the configured destination (§Module Sync). */
+ data class BackupNow(
+ override val source: SourceRef,
+ ) : LifeAction
+
+ /** Rebuilds the semantic index (§Module Recall). */
+ data class ReindexRecall(
+ override val source: SourceRef,
+ ) : LifeAction
+
+ /** Places time-sensitive tasks into free calendar slots (§Module Plan). */
+ data class PlanDay(
+ val dayOffset: Int,
+ override val source: SourceRef,
+ ) : LifeAction
+
+ /** Speaks text out loud through the offline voice (§Module Voice). */
+ data class Speak(
+ val text: String,
+ override val source: SourceRef,
+ ) : LifeAction
}
diff --git a/core/service/src/main/kotlin/com/lifeos/core/service/LifeEvent.kt b/core/service/src/main/kotlin/com/lifeos/core/service/LifeEvent.kt
index a3d52be..b99d56d 100644
--- a/core/service/src/main/kotlin/com/lifeos/core/service/LifeEvent.kt
+++ b/core/service/src/main/kotlin/com/lifeos/core/service/LifeEvent.kt
@@ -44,4 +44,22 @@ sealed interface LifeEvent {
val title: String?,
val text: String?,
) : LifeEvent
+
+ /** Arrived at a saved place (§Module Places). */
+ data class PlaceEntered(val placeId: Long, val name: String) : LifeEvent
+
+ /** Left a saved place. */
+ data class PlaceLeft(val placeId: Long, val name: String) : LifeEvent
+
+ /** A notification the Signals module captured, with its app label. */
+ data class SignalCaptured(
+ val signalId: Long,
+ val appPackage: String,
+ val appLabel: String,
+ val title: String,
+ val text: String,
+ ) : LifeEvent
+
+ /** Screen time for today crossed a threshold the rules care about. */
+ data class ScreenTimeCrossed(val minutesToday: Int) : LifeEvent
}
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 0e072fd..ec7e3d4 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
@@ -97,6 +97,15 @@ sealed interface LifeDestination {
@Serializable
data object ClearSky : LifeDestination
+
+ @Serializable
+ data object Triggers : LifeDestination
+
+ @Serializable
+ data object Signals : LifeDestination
+
+ @Serializable
+ data object Sync : LifeDestination
}
const val DEEP_LINK_SCHEME = "lifeos"
diff --git a/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/TopLevelDestination.kt b/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/TopLevelDestination.kt
index eb3b4ad..2852a10 100644
--- a/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/TopLevelDestination.kt
+++ b/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/TopLevelDestination.kt
@@ -19,6 +19,8 @@ import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.AutoAwesomeMosaic
import androidx.compose.material.icons.filled.Bolt
+import androidx.compose.material.icons.filled.CloudUpload
+import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.DocumentScanner
import androidx.compose.material.icons.filled.Download
@@ -39,6 +41,8 @@ import androidx.compose.material.icons.outlined.AccountBalanceWallet
import androidx.compose.material.icons.outlined.Archive
import androidx.compose.material.icons.outlined.AutoAwesomeMosaic
import androidx.compose.material.icons.outlined.Bolt
+import androidx.compose.material.icons.outlined.CloudUpload
+import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.ContentPaste
import androidx.compose.material.icons.outlined.DocumentScanner
import androidx.compose.material.icons.outlined.Download
@@ -121,4 +125,7 @@ enum class TopLevelDestination(
SCREEN_TIME("Screen Time", Icons.Filled.Timelapse, Icons.Outlined.Timelapse, LifeDestination.ScreenTime),
PASTEBIN("Pastebin", Icons.Filled.ContentPaste, Icons.Outlined.ContentPaste, LifeDestination.Pastebin),
SKY("Clear Sky", Icons.Filled.NightsStay, Icons.Outlined.NightsStay, LifeDestination.ClearSky),
+ TRIGGERS("Triggers", Icons.Filled.Bolt, Icons.Outlined.Bolt, LifeDestination.Triggers),
+ SIGNALS("Signals", Icons.Filled.Notifications, Icons.Outlined.Notifications, LifeDestination.Signals),
+ SYNC("Sync", Icons.Filled.CloudUpload, Icons.Outlined.CloudUpload, LifeDestination.Sync),
}
diff --git a/core/voice/build.gradle.kts b/core/voice/build.gradle.kts
new file mode 100644
index 0000000..4618754
--- /dev/null
+++ b/core/voice/build.gradle.kts
@@ -0,0 +1,11 @@
+plugins {
+ alias(libs.plugins.lifeos.android.library)
+ alias(libs.plugins.lifeos.hilt)
+}
+
+dependencies {
+ implementation(projects.core.common)
+ implementation(projects.core.service)
+
+ implementation(libs.androidx.core.ktx)
+}
diff --git a/core/voice/src/main/AndroidManifest.xml b/core/voice/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..b2d3ea1
--- /dev/null
+++ b/core/voice/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/core/voice/src/main/kotlin/com/lifeos/core/voice/Speaker.kt b/core/voice/src/main/kotlin/com/lifeos/core/voice/Speaker.kt
new file mode 100644
index 0000000..2ab1265
--- /dev/null
+++ b/core/voice/src/main/kotlin/com/lifeos/core/voice/Speaker.kt
@@ -0,0 +1,103 @@
+package com.lifeos.core.voice
+
+import android.content.Context
+import android.speech.tts.TextToSpeech
+import android.speech.tts.UtteranceProgressListener
+import com.lifeos.core.common.log.LifeLogger
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.util.Locale
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Jarvis's voice (§Module Voice).
+ *
+ * Uses the device's own speech engine, which on a Samsung phone is Samsung TTS
+ * and runs on-device once its language is downloaded - nothing is sent anywhere.
+ * The engine is created lazily on the first line spoken, because initialising it
+ * eagerly costs a few hundred milliseconds of app start for a feature most turns
+ * never use.
+ */
+@Singleton
+class Speaker @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+
+ private var engine: TextToSpeech? = null
+ private var ready = false
+
+ private val _speaking = MutableStateFlow(false)
+ val speaking = _speaking.asStateFlow()
+
+ /** Speaks [text], replacing anything already being said. */
+ fun speak(text: String) {
+ val clean = text
+ .replace(Regex("\\[\\[[^\\]]*]]"), " ")
+ .replace(Regex("[*_#`>]"), "")
+ .replace(Regex("\\s+"), " ")
+ .trim()
+ .take(MAX_CHARS)
+ if (clean.isBlank()) return
+ withEngine { tts ->
+ tts.speak(clean, TextToSpeech.QUEUE_FLUSH, null, UTTERANCE_ID)
+ }
+ }
+
+ fun stop() {
+ engine?.stop()
+ _speaking.value = false
+ }
+
+ /** Frees the engine; called when the app trims memory. */
+ fun release() {
+ runCatching {
+ engine?.stop()
+ engine?.shutdown()
+ }
+ engine = null
+ ready = false
+ _speaking.value = false
+ }
+
+ private fun withEngine(block: (TextToSpeech) -> Unit) {
+ val existing = engine
+ if (existing != null && ready) {
+ block(existing)
+ return
+ }
+ val pending = TextToSpeech(context) { status ->
+ ready = status == TextToSpeech.SUCCESS
+ val tts = engine
+ if (!ready || tts == null) {
+ LifeLogger.w(TAG, "No usable speech engine on this device")
+ return@TextToSpeech
+ }
+ runCatching { tts.language = Locale.getDefault() }
+ block(tts)
+ }
+ pending.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
+ override fun onStart(utteranceId: String?) {
+ _speaking.value = true
+ }
+
+ override fun onDone(utteranceId: String?) {
+ _speaking.value = false
+ }
+
+ @Deprecated("Required by the platform interface", ReplaceWith(""))
+ override fun onError(utteranceId: String?) {
+ _speaking.value = false
+ }
+ })
+ engine = pending
+ }
+
+ private companion object {
+ const val TAG = "Speaker"
+ const val UTTERANCE_ID = "jarvis"
+ /** Long replies get cut: reading a wall of text aloud is nobody's plan. */
+ const val MAX_CHARS = 1200
+ }
+}
diff --git a/core/voice/src/main/kotlin/com/lifeos/core/voice/VoiceJarvisBridge.kt b/core/voice/src/main/kotlin/com/lifeos/core/voice/VoiceJarvisBridge.kt
new file mode 100644
index 0000000..caa057a
--- /dev/null
+++ b/core/voice/src/main/kotlin/com/lifeos/core/voice/VoiceJarvisBridge.kt
@@ -0,0 +1,33 @@
+package com.lifeos.core.voice
+
+import com.lifeos.core.common.result.LifeResult
+import com.lifeos.core.service.LifeAction
+import com.lifeos.core.service.LifeActionHandler
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
+import javax.inject.Inject
+
+/** Lets any module, and Jarvis himself, say something out loud. */
+internal class SpeakActionHandler @Inject constructor(
+ private val speaker: Speaker,
+) : LifeActionHandler {
+
+ override fun canHandle(action: LifeAction): Boolean = action is LifeAction.Speak
+
+ override suspend fun execute(action: LifeAction): LifeResult {
+ speaker.speak((action as LifeAction.Speak).text)
+ return LifeResult.Success(null)
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal abstract class VoiceModule {
+
+ @Binds
+ @IntoSet
+ abstract fun bindSpeakHandler(impl: SpeakActionHandler): LifeActionHandler
+}
diff --git a/feature/chat/src/main/kotlin/com/lifeos/feature/chat/data/ChatRepository.kt b/feature/chat/src/main/kotlin/com/lifeos/feature/chat/data/ChatRepository.kt
index d58ff21..ccd911b 100644
--- a/feature/chat/src/main/kotlin/com/lifeos/feature/chat/data/ChatRepository.kt
+++ b/feature/chat/src/main/kotlin/com/lifeos/feature/chat/data/ChatRepository.kt
@@ -5,6 +5,7 @@ import com.lifeos.core.ai.model.AiEngineId
import com.lifeos.core.ai.model.AiMessage
import com.lifeos.core.ai.model.AiRequest
import com.lifeos.core.ai.model.AiRole
+import com.lifeos.core.ai.model.REPLACE_ALL
import com.lifeos.core.common.result.LifeError
import com.lifeos.core.database.chat.AiConversationEntity
import com.lifeos.core.database.chat.AiMessageEntity
@@ -136,9 +137,16 @@ internal class DefaultChatRepository @Inject constructor(
emit(ReplyProgress.Started(convId, event.engine))
}
is AiRouter.StreamEvent.Chunk -> {
- accumulated.append(event.chunk.text)
- persistAssistant()
- emit(ReplyProgress.Delta(accumulated.toString()))
+ // Streaming engines end with a sentinel plus the cleaned
+ // full text, so the visible reply never keeps raw
+ // fragments (or a stray turn token split across two).
+ if (event.chunk.text == REPLACE_ALL) {
+ accumulated.setLength(0)
+ } else {
+ accumulated.append(event.chunk.text)
+ persistAssistant()
+ emit(ReplyProgress.Delta(accumulated.toString()))
+ }
}
is AiRouter.StreamEvent.Failed -> {
debug.add("error", event.error.message)
@@ -150,19 +158,32 @@ internal class DefaultChatRepository @Inject constructor(
runPass()
- val reads = toolbox.requestedReads(accumulated.toString())
- if (reads.isNotEmpty()) {
+ // Multi-hop tool use (§Module 9 v2): each pass may ask for one more
+ // module read, so "compare my screen time with my focus streak" works
+ // without either being in the always-on prompt. Bounded hard - a small
+ // model left to loop will happily ask forever.
+ var hop = 0
+ var fetchedSoFar = ""
+ while (hop < MAX_TOOL_HOPS) {
+ val reads = toolbox.requestedReads(accumulated.toString())
+ if (reads.isEmpty()) break
val fetched = runCatching { toolbox.fetchReads(reads, debug) }.getOrDefault("")
- if (fetched.isNotBlank()) {
- debug.add("fetched", fetched)
- accumulated.setLength(0)
- request = AiRequest(
- messages = trimmedHistory,
- system = system + "\n\nFETCHED DATA (you asked for this; answer from it now, " +
- "do not emit another [[get:]]):\n" + fetched,
- )
- runPass()
- }
+ if (fetched.isBlank()) break
+ fetchedSoFar = (fetchedSoFar + "\n\n" + fetched).trim()
+ debug.add("fetched", fetched)
+ accumulated.setLength(0)
+ val lastHop = hop == MAX_TOOL_HOPS - 1
+ request = AiRequest(
+ messages = trimmedHistory,
+ system = system + "\n\nFETCHED DATA (you asked for this):\n" + fetchedSoFar +
+ if (lastHop) {
+ "\nAnswer now from this data. Do not emit [[get:]] again."
+ } else {
+ "\nAnswer from this data, or ask for ONE more topic if you truly need it."
+ },
+ )
+ runPass()
+ hop++
}
if (accumulated.isNotEmpty()) {
@@ -207,6 +228,8 @@ internal class DefaultChatRepository @Inject constructor(
}
private companion object {
+ /** One initial pass plus at most this many tool hops. */
+ const val MAX_TOOL_HOPS = 2
const val ROLE_USER = "user"
const val ROLE_ASSISTANT = "assistant"
const val SYSTEM_PROMPT =
diff --git a/feature/chat/src/main/kotlin/com/lifeos/feature/chat/data/JarvisToolbox.kt b/feature/chat/src/main/kotlin/com/lifeos/feature/chat/data/JarvisToolbox.kt
index 1e0b73d..0a2ebb4 100644
--- a/feature/chat/src/main/kotlin/com/lifeos/feature/chat/data/JarvisToolbox.kt
+++ b/feature/chat/src/main/kotlin/com/lifeos/feature/chat/data/JarvisToolbox.kt
@@ -73,6 +73,8 @@ class JarvisToolbox @Inject constructor(
[[brick_on: mode name]] [[brick_off:]] [[focus: 25m]] [[focus_stop:]]
[[sync_screen_time:]] [[export_screen_time: json|csv_days|csv_apps]]
[[run_macro: name]]
+ [[rule: name | TRIGGER arg | ACTION arg]] [[rule_on: name]] [[rule_off: name]] [[run_rule: name]]
+ [[backup:]] [[reindex:]] [[plan_day: today|tomorrow]] [[say: text to speak]]
To READ a module in detail, emit ONE line and stop; the answer comes back to you:
[[get: topic]] or [[get: topic | query]] — topics: ${topicList()}
Rules: use [[get:]] only when the answer needs data that is not already in LIVE DATA
@@ -386,6 +388,57 @@ class JarvisToolbox @Inject constructor(
"Ran macro \"${args.trim()}\""
}
+ "rule" -> {
+ // "name | TRIGGER arg | ACTION arg" - the engine validates the types.
+ val parts = args.split('|').map { it.trim() }
+ if (parts.size < 3) error("need: name | TRIGGER arg | ACTION arg")
+ val trigger = parts[1].split(' ', limit = 2)
+ val actionSpec = parts[2].split(' ', limit = 2)
+ dispatch(
+ LifeAction.CreateTriggerRule(
+ name = parts[0],
+ triggerType = trigger.first(),
+ triggerArg = trigger.getOrNull(1).orEmpty(),
+ actionType = actionSpec.first(),
+ actionArg = actionSpec.getOrNull(1).orEmpty(),
+ days = parts.getOrNull(3).orEmpty(),
+ source = SOURCE,
+ ),
+ )
+ "Rule \"${parts[0]}\" created"
+ }
+
+ "rule_on", "rule_off" -> {
+ dispatch(LifeAction.SetTriggerRuleEnabled(args.trim(), tool == "rule_on", SOURCE))
+ if (tool == "rule_on") "Rule \"${args.trim()}\" is on" else "Rule \"${args.trim()}\" is off"
+ }
+
+ "run_rule" -> {
+ dispatch(LifeAction.RunTriggerRule(args.trim(), SOURCE))
+ "Ran rule \"${args.trim()}\""
+ }
+
+ "backup" -> {
+ dispatch(LifeAction.BackupNow(SOURCE))
+ "Backup written and verified: ${echo.lastFileName ?: "done"}"
+ }
+
+ "reindex" -> {
+ dispatch(LifeAction.ReindexRecall(SOURCE))
+ "Recall index refreshed"
+ }
+
+ "plan_day" -> {
+ val offset = if (args.contains("tomorrow", ignoreCase = true)) 1 else 0
+ dispatch(LifeAction.PlanDay(offset, SOURCE))
+ if (offset == 0) "Today is planned" else "Tomorrow is planned"
+ }
+
+ "say" -> {
+ dispatch(LifeAction.Speak(args.trim(), SOURCE))
+ null
+ }
+
// Reads are handled before this point (they feed a second pass).
"get" -> null
else -> null
diff --git a/feature/planner/src/main/kotlin/com/lifeos/feature/planner/data/DayPlanner.kt b/feature/planner/src/main/kotlin/com/lifeos/feature/planner/data/DayPlanner.kt
new file mode 100644
index 0000000..3de24fd
--- /dev/null
+++ b/feature/planner/src/main/kotlin/com/lifeos/feature/planner/data/DayPlanner.kt
@@ -0,0 +1,136 @@
+package com.lifeos.feature.planner.data
+
+import com.lifeos.core.database.calendar.CalendarDao
+import com.lifeos.core.database.capture.CaptureDao
+import com.lifeos.core.database.capture.TaskEntity
+import com.lifeos.core.model.LifeModule
+import com.lifeos.core.model.SourceRef
+import com.lifeos.core.service.LifeAction
+import com.lifeos.core.service.LifeActionDispatcher
+import kotlinx.coroutines.flow.first
+import java.util.Calendar
+import javax.inject.Inject
+import javax.inject.Provider
+import javax.inject.Singleton
+
+/** One placement the planner made, so the reply can list them. */
+data class PlacedBlock(
+ val title: String,
+ val startsAt: Long,
+ val endsAt: Long,
+)
+
+/**
+ * Auto-scheduling (§Module Plan).
+ *
+ * Tasks and the calendar stop being two lists: due-dated tasks are placed into
+ * real gaps between existing events, inside working hours, newest deadline
+ * first. Every placement is an ordinary calendar event, so it can be moved or
+ * deleted like anything else - nothing here is hidden magic.
+ */
+@Singleton
+class DayPlanner @Inject constructor(
+ private val calendarDao: CalendarDao,
+ private val captureDao: CaptureDao,
+ /**
+ * Lazily: the planner creates events through the dispatcher, and the
+ * planner's own action handler is registered in it - direct injection is a
+ * dependency cycle.
+ */
+ private val dispatcherProvider: Provider,
+) {
+
+ private val dispatcher: LifeActionDispatcher get() = dispatcherProvider.get()
+
+ /**
+ * Plans one day.
+ *
+ * @param dayOffset 0 = today, 1 = tomorrow.
+ * @return the blocks that were created, in order.
+ */
+ suspend fun plan(dayOffset: Int = 0): List {
+ val dayStart = startOfDay(dayOffset)
+ val dayEnd = dayStart + DAY_MS
+ val windowStart = maxOf(dayStart + WORK_START_MS, roundUpToQuarter(System.currentTimeMillis()))
+ val windowEnd = dayStart + WORK_END_MS
+ if (windowStart >= windowEnd) return emptyList()
+
+ val events = calendarDao.observeWindow(dayStart, dayEnd).first().sortedBy { it.startsAt }
+ val busy = events.map { it.startsAt to it.endsAt }
+ val candidates = openTasks(dayEnd)
+ if (candidates.isEmpty()) return emptyList()
+
+ val placed = mutableListOf()
+ var cursor = windowStart
+ for (task in candidates) {
+ val slot = nextFreeSlot(cursor, windowEnd, busy + placed.map { it.startsAt to it.endsAt })
+ ?: break
+ val length = minOf(BLOCK_MS, slot.second - slot.first)
+ if (length < MIN_BLOCK_MS) break
+ val start = slot.first
+ val end = start + length
+ dispatcher.dispatch(
+ LifeAction.CreateCalendarEvent(
+ title = "Focus: ${task.title.take(60)}",
+ startsAt = start,
+ endsAt = end,
+ source = SOURCE,
+ ),
+ )
+ placed += PlacedBlock("Focus: ${task.title.take(60)}", start, end)
+ cursor = end + GAP_MS
+ if (placed.size >= MAX_BLOCKS) break
+ }
+ return placed
+ }
+
+ /** Open tasks worth scheduling: due soonest first, then oldest. */
+ private suspend fun openTasks(dayEnd: Long): List {
+ val tasks = captureDao.observeTasks().first().filter { !it.done }
+ val due = tasks
+ .mapNotNull { task -> task.dueAt?.let { due -> task to due } }
+ .filter { (_, due) -> due <= dayEnd + 3 * DAY_MS }
+ .sortedBy { (_, due) -> due }
+ .map { (task, _) -> task }
+ val rest = tasks.filter { it.dueAt == null }.sortedBy { it.createdAt }
+ return (due + rest).take(MAX_BLOCKS)
+ }
+
+ /** First gap of at least [MIN_BLOCK_MS] at or after [from]. */
+ private fun nextFreeSlot(from: Long, until: Long, busy: List>): Pair? {
+ var cursor = from
+ val sorted = busy.sortedBy { it.first }
+ for ((start, end) in sorted) {
+ if (end <= cursor) continue
+ if (start - cursor >= MIN_BLOCK_MS) return cursor to minOf(start, until)
+ cursor = maxOf(cursor, end)
+ if (cursor >= until) return null
+ }
+ return if (until - cursor >= MIN_BLOCK_MS) cursor to until else null
+ }
+
+ private fun startOfDay(offset: Int): Long = Calendar.getInstance().apply {
+ add(Calendar.DAY_OF_YEAR, offset)
+ set(Calendar.HOUR_OF_DAY, 0)
+ set(Calendar.MINUTE, 0)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }.timeInMillis
+
+ private fun roundUpToQuarter(millis: Long): Long {
+ val quarter = 15 * 60_000L
+ return ((millis + quarter - 1) / quarter) * quarter
+ }
+
+ private companion object {
+ val SOURCE = SourceRef(LifeModule.PLANNER, "auto-plan")
+ const val DAY_MS = 86_400_000L
+ /** Working window: 09:00 to 19:00. */
+ const val WORK_START_MS = 9 * 3_600_000L
+ const val WORK_END_MS = 19 * 3_600_000L
+ const val BLOCK_MS = 50 * 60_000L
+ const val MIN_BLOCK_MS = 25 * 60_000L
+ const val GAP_MS = 10 * 60_000L
+ const val MAX_BLOCKS = 5
+ }
+}
diff --git a/feature/planner/src/main/kotlin/com/lifeos/feature/planner/data/PlanJarvisBridge.kt b/feature/planner/src/main/kotlin/com/lifeos/feature/planner/data/PlanJarvisBridge.kt
new file mode 100644
index 0000000..d88faab
--- /dev/null
+++ b/feature/planner/src/main/kotlin/com/lifeos/feature/planner/data/PlanJarvisBridge.kt
@@ -0,0 +1,85 @@
+package com.lifeos.feature.planner.data
+
+import com.lifeos.core.common.result.LifeError
+import com.lifeos.core.common.result.LifeResult
+import com.lifeos.core.database.calendar.CalendarDao
+import com.lifeos.core.database.capture.CaptureDao
+import com.lifeos.core.service.LifeAction
+import com.lifeos.core.service.LifeActionHandler
+import com.lifeos.core.service.LifeDataProvider
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
+import kotlinx.coroutines.flow.first
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import javax.inject.Inject
+
+/** The shape of a day as Jarvis reads it: what is booked, what is waiting. */
+internal class PlanProvider @Inject constructor(
+ private val calendarDao: CalendarDao,
+ private val captureDao: CaptureDao,
+) : LifeDataProvider {
+
+ override val topic: String = "plan"
+ override val description: String = "today's shape: booked blocks, free gaps and unscheduled tasks"
+
+ override suspend fun read(query: String?): String {
+ val offset = query?.let { if (it.contains("tomorrow", ignoreCase = true)) 1 else null } ?: 0
+ val dayStart = System.currentTimeMillis() - System.currentTimeMillis() % 86_400_000L + offset * 86_400_000L
+ val events = calendarDao.observeWindow(dayStart, dayStart + 86_400_000L).first().sortedBy { it.startsAt }
+ val tasks = captureDao.observeTasks().first().filter { !it.done }
+ return buildString {
+ appendLine(if (offset == 0) "Today:" else "Tomorrow:")
+ if (events.isEmpty()) {
+ appendLine("- nothing booked")
+ } else {
+ events.forEach {
+ appendLine("- ${AT.format(Date(it.startsAt))}-${AT.format(Date(it.endsAt))} ${it.title}")
+ }
+ }
+ val dated = tasks.filter { it.dueAt != null }.sortedBy { it.dueAt }
+ if (dated.isNotEmpty()) {
+ appendLine("Due soon:")
+ dated.take(6).forEach { appendLine("- [${it.id}] ${it.title} (${AT.format(Date(it.dueAt!!))})") }
+ }
+ appendLine("Unscheduled tasks: ${tasks.count { it.dueAt == null }}")
+ }.trim()
+ }
+
+ private companion object {
+ val AT = SimpleDateFormat("HH:mm", Locale.getDefault())
+ }
+}
+
+internal class PlanActionHandler @Inject constructor(
+ private val planner: DayPlanner,
+) : LifeActionHandler {
+
+ override fun canHandle(action: LifeAction): Boolean = action is LifeAction.PlanDay
+
+ override suspend fun execute(action: LifeAction): LifeResult {
+ val placed = planner.plan((action as LifeAction.PlanDay).dayOffset)
+ return if (placed.isEmpty()) {
+ LifeResult.Failure(LifeError.Validation("No free slot long enough, or nothing to schedule"))
+ } else {
+ LifeResult.Success(placed.size.toLong())
+ }
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal abstract class PlanJarvisModule {
+
+ @Binds
+ @IntoSet
+ abstract fun bindProvider(impl: PlanProvider): LifeDataProvider
+
+ @Binds
+ @IntoSet
+ abstract fun bindHandler(impl: PlanActionHandler): LifeActionHandler
+}
diff --git a/feature/signals/build.gradle.kts b/feature/signals/build.gradle.kts
new file mode 100644
index 0000000..3fd1737
--- /dev/null
+++ b/feature/signals/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.service)
+ 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)
+}
diff --git a/feature/signals/src/main/AndroidManifest.xml b/feature/signals/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..cc72bd1
--- /dev/null
+++ b/feature/signals/src/main/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/signals/src/main/kotlin/com/lifeos/feature/signals/SignalsScreen.kt b/feature/signals/src/main/kotlin/com/lifeos/feature/signals/SignalsScreen.kt
new file mode 100644
index 0000000..9339e1f
--- /dev/null
+++ b/feature/signals/src/main/kotlin/com/lifeos/feature/signals/SignalsScreen.kt
@@ -0,0 +1,190 @@
+package com.lifeos.feature.signals
+
+import android.content.Intent
+import androidx.compose.foundation.layout.Arrangement
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.DoneAll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.lifeos.core.database.signals.SignalDao
+import com.lifeos.core.database.signals.SignalEntity
+import com.lifeos.core.designsystem.component.EmptyState
+import com.lifeos.core.designsystem.component.FadeThrough
+import com.lifeos.feature.signals.data.SignalDigest
+import com.lifeos.feature.signals.data.SignalListenerService
+import dagger.hilt.android.lifecycle.HiltViewModel
+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 java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import javax.inject.Inject
+
+@HiltViewModel
+class SignalsViewModel @Inject constructor(
+ private val signalDao: SignalDao,
+ private val digest: SignalDigest,
+) : ViewModel() {
+
+ val signals = signalDao.observeRecent()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
+ private val _digestText = MutableStateFlow(null)
+ val digestText = _digestText.asStateFlow()
+
+ private val _filter = MutableStateFlow("ALL")
+ val filter = _filter.asStateFlow()
+
+ fun setFilter(value: String) { _filter.value = value }
+
+ fun buildDigest(hours: Int = 12) {
+ viewModelScope.launch { _digestText.value = digest.build(hours) }
+ }
+
+ fun markAllRead() {
+ viewModelScope.launch { signalDao.markAllRead(System.currentTimeMillis()) }
+ }
+
+ fun clear() {
+ viewModelScope.launch {
+ signalDao.clear()
+ _digestText.value = null
+ }
+ }
+}
+
+/**
+ * Signals (§Module Signals): every notification LifeOS captured, grouped into a
+ * digest so "what did I miss" is one screen instead of a scroll of interruptions.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SignalsRoute(viewModel: SignalsViewModel = hiltViewModel()) {
+ val signals by viewModel.signals.collectAsState()
+ val digest by viewModel.digestText.collectAsState()
+ val filter by viewModel.filter.collectAsState()
+ val context = LocalContext.current
+ val granted = SignalListenerService.isGranted(context)
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Signals") },
+ actions = {
+ IconButton(onClick = viewModel::markAllRead) {
+ Icon(Icons.Filled.DoneAll, contentDescription = "Mark all read")
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ if (!granted) {
+ Card {
+ Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Notification access needed", style = MaterialTheme.typography.titleMedium)
+ Text(
+ "LifeOS can only see notifications after you allow it once. Nothing is uploaded; " +
+ "rows older than 30 days are deleted automatically.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Button(
+ onClick = {
+ runCatching {
+ context.startActivity(
+ Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS"),
+ )
+ }
+ },
+ ) { Text("Open notification access") }
+ }
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ listOf("ALL" to "All", "CODE" to "Codes", "PARCEL" to "Parcels", "RECEIPT" to "Money")
+ .forEach { (value, label) ->
+ FilterChip(
+ selected = filter == value,
+ onClick = { viewModel.setFilter(value) },
+ label = { Text(label) },
+ )
+ }
+ Button(onClick = { viewModel.buildDigest() }) { Text("Digest") }
+ }
+ FadeThrough(targetState = digest, label = "signals-digest") { text ->
+ if (text != null) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Text(text, modifier = Modifier.padding(14.dp), style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+ val visible = signals.filter { filter == "ALL" || it.extracted == filter }
+ if (visible.isEmpty()) {
+ EmptyState(
+ title = if (granted) "Nothing captured yet" else "Waiting for access",
+ description = "Captured notifications land here, tagged when they look like a code, " +
+ "a parcel update or something you paid for.",
+ )
+ } else {
+ LazyColumn {
+ items(visible, key = { it.id }) { signal -> SignalRow(signal) }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SignalRow(signal: SignalEntity) {
+ ListItem(
+ headlineContent = { Text(signal.title.ifBlank { signal.appLabel }, maxLines = 1) },
+ supportingContent = { Text(signal.text.take(140), maxLines = 2) },
+ trailingContent = {
+ Text(
+ TIME.format(Date(signal.postedAt)),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ overlineContent = {
+ Text(
+ if (signal.extracted == "NONE") signal.appLabel else "${signal.appLabel} - ${signal.extracted}",
+ style = MaterialTheme.typography.labelSmall,
+ )
+ },
+ )
+}
+
+private val TIME = SimpleDateFormat("EEE HH:mm", Locale.getDefault())
diff --git a/feature/signals/src/main/kotlin/com/lifeos/feature/signals/data/SignalListenerService.kt b/feature/signals/src/main/kotlin/com/lifeos/feature/signals/data/SignalListenerService.kt
new file mode 100644
index 0000000..7c07582
--- /dev/null
+++ b/feature/signals/src/main/kotlin/com/lifeos/feature/signals/data/SignalListenerService.kt
@@ -0,0 +1,141 @@
+package com.lifeos.feature.signals.data
+
+import android.app.Notification
+import android.content.ComponentName
+import android.content.Context
+import android.provider.Settings
+import android.service.notification.NotificationListenerService
+import android.service.notification.StatusBarNotification
+import com.lifeos.core.common.log.LifeLogger
+import com.lifeos.core.database.signals.SignalDao
+import com.lifeos.core.database.signals.SignalEntity
+import com.lifeos.core.service.LifeEvent
+import com.lifeos.core.service.LifeEventBus
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Captures notifications so LifeOS can answer "what did I miss?" (§Module
+ * Signals).
+ *
+ * Deliberately conservative: ongoing and group-summary notifications are
+ * skipped, LifeOS's own are ignored, an identical title/text from the same app
+ * inside a minute counts once, and rows are trimmed to [KEEP_DAYS]. Everything
+ * stays in the local database.
+ */
+@AndroidEntryPoint
+class SignalListenerService : NotificationListenerService() {
+
+ @Inject
+ lateinit var signalDao: SignalDao
+
+ @Inject
+ lateinit var eventBus: LifeEventBus
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val recent = mutableMapOf()
+
+ override fun onListenerConnected() {
+ super.onListenerConnected()
+ connected = true
+ LifeLogger.i(TAG, "Signal listener connected")
+ }
+
+ override fun onListenerDisconnected() {
+ connected = false
+ super.onListenerDisconnected()
+ }
+
+ override fun onNotificationPosted(sbn: StatusBarNotification?) {
+ val notification = sbn?.notification ?: return
+ if (sbn.packageName == packageName) return
+ if (notification.flags and Notification.FLAG_ONGOING_EVENT != 0) return
+ if (notification.flags and Notification.FLAG_GROUP_SUMMARY != 0) return
+
+ val extras = notification.extras
+ val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString()?.trim().orEmpty()
+ val text = (
+ extras?.getCharSequence(Notification.EXTRA_TEXT)
+ ?: extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)
+ )?.toString()?.trim().orEmpty()
+ if (title.isBlank() && text.isBlank()) return
+
+ val key = "${sbn.packageName}|$title|$text"
+ val now = System.currentTimeMillis()
+ val previous = recent[key]
+ if (previous != null && now - previous < DEDUPE_MS) return
+ recent[key] = now
+ if (recent.size > 200) recent.entries.removeIf { now - it.value > DEDUPE_MS }
+
+ val label = runCatching {
+ val info = packageManager.getApplicationInfo(sbn.packageName, 0)
+ packageManager.getApplicationLabel(info).toString()
+ }.getOrDefault(sbn.packageName)
+
+ scope.launch {
+ val id = signalDao.insert(
+ SignalEntity(
+ appPackage = sbn.packageName,
+ appLabel = label,
+ title = title.take(160),
+ text = text.take(600),
+ postedAt = now,
+ extracted = classify(title, text),
+ ),
+ )
+ signalDao.trim(now - KEEP_DAYS * 86_400_000L)
+ // Rules listen on the bus, so a captured notification can start a macro.
+ eventBus.publish(
+ LifeEvent.SignalCaptured(
+ signalId = id,
+ appPackage = sbn.packageName,
+ appLabel = label,
+ title = title,
+ text = text,
+ ),
+ )
+ }
+ }
+
+ /** Cheap, local tagging so the digest can group things worth acting on. */
+ private fun classify(title: String, text: String): String {
+ val joined = "$title $text"
+ return when {
+ CODE.containsMatchIn(joined) -> "CODE"
+ PARCEL.containsMatchIn(joined) -> "PARCEL"
+ RECEIPT.containsMatchIn(joined) -> "RECEIPT"
+ else -> "NONE"
+ }
+ }
+
+ companion object {
+ @Volatile
+ var connected: Boolean = false
+ private set
+
+ private const val TAG = "SignalListener"
+ private const val DEDUPE_MS = 60_000L
+ private const val KEEP_DAYS = 30
+
+ private val CODE = Regex("(?i)\\b(code|otp|verification|2fa|einmal)\\b.*?\\b(\\d{4,8})\\b")
+ private val PARCEL = Regex("(?i)\\b(parcel|paket|shipment|sendung|delivery|zustellung|dhl|hermes|ups|gls)\\b")
+ private val RECEIPT = Regex("(?i)\\b(receipt|invoice|rechnung|beleg|payment|zahlung|abgebucht)\\b")
+
+ /** True when the user has granted notification access to LifeOS. */
+ fun isGranted(context: Context): Boolean {
+ val enabled = Settings.Secure.getString(
+ context.contentResolver,
+ "enabled_notification_listeners",
+ ) ?: return false
+ val component = ComponentName(context, SignalListenerService::class.java)
+ return enabled.split(':').any {
+ it.equals(component.flattenToString(), ignoreCase = true) ||
+ it.equals(component.flattenToShortString(), ignoreCase = true)
+ }
+ }
+ }
+}
diff --git a/feature/signals/src/main/kotlin/com/lifeos/feature/signals/data/SignalsJarvisBridge.kt b/feature/signals/src/main/kotlin/com/lifeos/feature/signals/data/SignalsJarvisBridge.kt
new file mode 100644
index 0000000..b88d593
--- /dev/null
+++ b/feature/signals/src/main/kotlin/com/lifeos/feature/signals/data/SignalsJarvisBridge.kt
@@ -0,0 +1,85 @@
+package com.lifeos.feature.signals.data
+
+import com.lifeos.core.database.signals.SignalDao
+import com.lifeos.core.service.LifeDataProvider
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Builds the "what did I miss" digest, shared by the screen and by Jarvis. */
+@Singleton
+class SignalDigest @Inject constructor(
+ private val signalDao: SignalDao,
+) {
+
+ suspend fun build(hours: Int = 12, limit: Int = 40): String {
+ val since = System.currentTimeMillis() - hours * 3_600_000L
+ val signals = signalDao.since(since)
+ if (signals.isEmpty()) return "Nothing captured in the last $hours hours."
+ val byApp = signals.groupBy { it.appLabel }
+ return buildString {
+ appendLine("${signals.size} notification(s) in the last $hours hours, ${byApp.size} app(s):")
+ byApp.entries
+ .sortedByDescending { it.value.size }
+ .take(8)
+ .forEach { (app, rows) ->
+ appendLine("$app (${rows.size}):")
+ rows.take(4).forEach { row ->
+ val line = listOf(row.title, row.text).filter { it.isNotBlank() }.joinToString(" - ")
+ appendLine(" - ${line.take(120)}")
+ }
+ }
+ val codes = signals.filter { it.extracted == "CODE" }
+ if (codes.isNotEmpty()) {
+ appendLine("Codes: " + codes.take(3).joinToString("; ") { "${it.appLabel}: ${it.text.take(60)}" })
+ }
+ val parcels = signals.filter { it.extracted == "PARCEL" }
+ if (parcels.isNotEmpty()) {
+ appendLine("Parcel updates: ${parcels.size}")
+ }
+ }.trim().take(limit * 60)
+ }
+}
+
+/** Signals as Jarvis reads them. */
+internal class SignalsProvider @Inject constructor(
+ private val digest: SignalDigest,
+ private val signalDao: SignalDao,
+) : LifeDataProvider {
+
+ override val topic: String = "signals"
+ override val description: String = "notifications you missed, grouped by app"
+
+ override suspend fun read(query: String?): String {
+ val hours = query?.filter { it.isDigit() }?.toIntOrNull()?.coerceIn(1, 72) ?: 12
+ val body = digest.build(hours)
+ if (query.isNullOrBlank()) return body
+ val needle = query.trim()
+ val matches = signalDao.since(System.currentTimeMillis() - 72 * 3_600_000L)
+ .filter {
+ it.appLabel.contains(needle, ignoreCase = true) ||
+ it.title.contains(needle, ignoreCase = true) ||
+ it.text.contains(needle, ignoreCase = true)
+ }
+ if (matches.isEmpty()) return body
+ return buildString {
+ appendLine("Matching \"$needle\":")
+ matches.take(10).forEach {
+ appendLine("- ${it.appLabel}: ${it.title} ${it.text}".take(160))
+ }
+ }.trim()
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal abstract class SignalsJarvisModule {
+
+ @Binds
+ @IntoSet
+ abstract fun bindProvider(impl: SignalsProvider): LifeDataProvider
+}
diff --git a/feature/sync/build.gradle.kts b/feature/sync/build.gradle.kts
new file mode 100644
index 0000000..af7e500
--- /dev/null
+++ b/feature/sync/build.gradle.kts
@@ -0,0 +1,21 @@
+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.service)
+ 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)
+ implementation(libs.okhttp)
+
+ testImplementation(libs.junit)
+}
diff --git a/feature/sync/src/main/AndroidManifest.xml b/feature/sync/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..19d2638
--- /dev/null
+++ b/feature/sync/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/feature/sync/src/main/kotlin/com/lifeos/feature/sync/SyncScreen.kt b/feature/sync/src/main/kotlin/com/lifeos/feature/sync/SyncScreen.kt
new file mode 100644
index 0000000..244fcdc
--- /dev/null
+++ b/feature/sync/src/main/kotlin/com/lifeos/feature/sync/SyncScreen.kt
@@ -0,0 +1,291 @@
+package com.lifeos.feature.sync
+
+import androidx.compose.foundation.layout.Arrangement
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LinearProgressIndicator
+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.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.lifeos.core.database.backup.BackupDao
+import com.lifeos.core.datastore.SettingsRepository
+import com.lifeos.feature.sync.data.BackupService
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import javax.inject.Inject
+
+data class SyncUiState(
+ val passphrase: String = "",
+ val webdavUrl: String = "",
+ val webdavUser: String = "",
+ val webdavPassword: String = "",
+ val keep: Int = 5,
+ val busy: Boolean = false,
+ val message: String? = null,
+ val localBackups: List = emptyList(),
+)
+
+@HiltViewModel
+class SyncViewModel @Inject constructor(
+ private val settingsRepository: SettingsRepository,
+ private val backupService: BackupService,
+ backupDao: BackupDao,
+) : ViewModel() {
+
+ val runs = backupDao.observeRuns()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
+ private val _uiState = MutableStateFlow(SyncUiState())
+ val uiState = _uiState.asStateFlow()
+
+ init {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(
+ passphrase = settingsRepository.backupPassphrase.first(),
+ webdavUrl = settingsRepository.backupWebdavUrl.first(),
+ webdavUser = settingsRepository.backupWebdavUser.first(),
+ webdavPassword = settingsRepository.backupWebdavPassword.first(),
+ keep = settingsRepository.backupKeepGenerations.first(),
+ localBackups = backupService.localBackups().map { it.name },
+ )
+ }
+ }
+
+ fun onPassphrase(value: String) {
+ _uiState.value = _uiState.value.copy(passphrase = value)
+ viewModelScope.launch { settingsRepository.setBackupPassphrase(value) }
+ }
+
+ fun onUrl(value: String) {
+ _uiState.value = _uiState.value.copy(webdavUrl = value)
+ viewModelScope.launch { settingsRepository.setBackupWebdavUrl(value) }
+ }
+
+ fun onUser(value: String) {
+ _uiState.value = _uiState.value.copy(webdavUser = value)
+ viewModelScope.launch { settingsRepository.setBackupWebdavUser(value) }
+ }
+
+ fun onPassword(value: String) {
+ _uiState.value = _uiState.value.copy(webdavPassword = value)
+ viewModelScope.launch { settingsRepository.setBackupWebdavPassword(value) }
+ }
+
+ fun onKeep(value: String) {
+ val keep = value.filter { it.isDigit() }.toIntOrNull() ?: return
+ _uiState.value = _uiState.value.copy(keep = keep)
+ viewModelScope.launch { settingsRepository.setBackupKeepGenerations(keep) }
+ }
+
+ fun backupNow() {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(busy = true, message = null)
+ val result = backupService.backupNow()
+ _uiState.value = _uiState.value.copy(
+ busy = false,
+ localBackups = backupService.localBackups().map { it.name },
+ message = result.fold(
+ onSuccess = { outcome ->
+ if (outcome.verified) {
+ "Backed up and verified: ${outcome.fileName}"
+ } else {
+ "Backed up but not verified: ${outcome.detail}"
+ }
+ },
+ onFailure = { it.message ?: "Backup failed" },
+ ),
+ )
+ }
+ }
+
+ fun restore(fileName: String) {
+ viewModelScope.launch {
+ val file = backupService.localBackups().firstOrNull { it.name == fileName } ?: return@launch
+ _uiState.value = _uiState.value.copy(busy = true)
+ val result = backupService.restoreFrom(file)
+ _uiState.value = _uiState.value.copy(
+ busy = false,
+ message = result.fold(onSuccess = { it }, onFailure = { it.message ?: "Restore failed" }),
+ )
+ }
+ }
+
+ fun dismissMessage() { _uiState.value = _uiState.value.copy(message = null) }
+}
+
+/**
+ * Sync (§Module Sync): encrypted snapshots of everything, kept locally and
+ * pushed to the NAS, with a restore that has actually been tested.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SyncRoute(viewModel: SyncViewModel = hiltViewModel()) {
+ val state by viewModel.uiState.collectAsState()
+ val runs by viewModel.runs.collectAsState()
+ val snackbarHostState = remember { SnackbarHostState() }
+ LaunchedEffect(state.message) {
+ state.message?.let {
+ snackbarHostState.showSnackbar(it)
+ viewModel.dismissMessage()
+ }
+ }
+
+ Scaffold(
+ topBar = { TopAppBar(title = { Text("Sync") }) },
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().padding(padding),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ item {
+ Card {
+ Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text("Everything, encrypted", style = MaterialTheme.typography.titleMedium)
+ Text(
+ "A snapshot is a consistent copy of the LifeOS database, encrypted on this phone " +
+ "with your passphrase (AES-256-GCM). It is written next to the app and, if you " +
+ "set a WebDAV URL, pushed to your NAS. Every run is read back and decrypted to " +
+ "prove it works - lose the passphrase and the backup is gone with it.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ item {
+ OutlinedTextField(
+ value = state.passphrase,
+ onValueChange = viewModel::onPassphrase,
+ label = { Text("Backup passphrase") },
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ item {
+ OutlinedTextField(
+ value = state.webdavUrl,
+ onValueChange = viewModel::onUrl,
+ label = { Text("WebDAV folder URL (optional)") },
+ placeholder = { Text("https://nas.local/remote.php/dav/files/me/LifeOS") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ item {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(
+ value = state.webdavUser,
+ onValueChange = viewModel::onUser,
+ label = { Text("User") },
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ )
+ OutlinedTextField(
+ value = state.webdavPassword,
+ onValueChange = viewModel::onPassword,
+ label = { Text("Password") },
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ item {
+ OutlinedTextField(
+ value = state.keep.toString(),
+ onValueChange = viewModel::onKeep,
+ label = { Text("Local snapshots to keep") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ item {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = viewModel::backupNow, enabled = !state.busy) {
+ Text(if (state.busy) "Working…" else "Back up now")
+ }
+ }
+ }
+ if (state.busy) item { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) }
+
+ if (state.localBackups.isNotEmpty()) {
+ item { Text("Restore", style = MaterialTheme.typography.titleMedium) }
+ items(state.localBackups) { name ->
+ ListItem(
+ headlineContent = { Text(name) },
+ trailingContent = {
+ OutlinedButton(onClick = { viewModel.restore(name) }, enabled = !state.busy) {
+ Text("Restore")
+ }
+ },
+ )
+ }
+ item {
+ Text(
+ "A restore is staged and applied the next time LifeOS starts cold - Room holds the " +
+ "database open, so that is the only safe moment to swap it.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ if (runs.isNotEmpty()) {
+ item { Text("History", style = MaterialTheme.typography.titleMedium) }
+ items(runs, key = { it.id }) { run ->
+ ListItem(
+ headlineContent = { Text("${run.status} ${run.fileName}") },
+ supportingContent = {
+ Text(
+ listOf(
+ AT.format(Date(run.at)),
+ "${run.sizeBytes / 1024} KB",
+ run.detail,
+ ).filter { it.isNotBlank() }.joinToString(" - "),
+ )
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+private val AT = SimpleDateFormat("d MMM HH:mm", Locale.getDefault())
diff --git a/feature/sync/src/main/kotlin/com/lifeos/feature/sync/data/BackupService.kt b/feature/sync/src/main/kotlin/com/lifeos/feature/sync/data/BackupService.kt
new file mode 100644
index 0000000..73f4fca
--- /dev/null
+++ b/feature/sync/src/main/kotlin/com/lifeos/feature/sync/data/BackupService.kt
@@ -0,0 +1,276 @@
+package com.lifeos.feature.sync.data
+
+import android.content.Context
+import com.lifeos.core.common.log.LifeLogger
+import com.lifeos.core.database.LifeDatabase
+import com.lifeos.core.database.backup.BackupDao
+import com.lifeos.core.database.backup.BackupRunEntity
+import com.lifeos.core.datastore.SettingsRepository
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withContext
+import okhttp3.Credentials
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.io.File
+import java.security.SecureRandom
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+import javax.crypto.Cipher
+import javax.crypto.Mac
+import javax.crypto.spec.GCMParameterSpec
+import javax.crypto.spec.SecretKeySpec
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Where a snapshot ended up and whether it read back intact. */
+data class BackupOutcome(
+ val fileName: String,
+ val sizeBytes: Long,
+ val destination: String,
+ val verified: Boolean,
+ val detail: String = "",
+)
+
+/**
+ * Encrypted database snapshots (§Module Sync).
+ *
+ * Everything in LifeOS lives in one sideloaded app on one phone, so the whole
+ * point here is that a lost phone is not a lost life. A snapshot is a consistent
+ * SQLite copy (`VACUUM INTO`, not a file copy of a live database), encrypted with
+ * AES-256-GCM from a passphrase, written to the LifeOS folder and optionally
+ * pushed to a WebDAV share on the NAS. Every run is verified by reading the
+ * result back and decrypting it - an unverified backup is not a backup.
+ */
+@Singleton
+class BackupService @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val database: LifeDatabase,
+ private val backupDao: BackupDao,
+ private val settingsRepository: SettingsRepository,
+) {
+
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(20, TimeUnit.SECONDS)
+ .writeTimeout(120, TimeUnit.SECONDS)
+ .readTimeout(120, TimeUnit.SECONDS)
+ .build()
+
+ private val random = SecureRandom()
+
+ /** Runs a backup end to end and records the attempt either way. */
+ suspend fun backupNow(): Result = withContext(Dispatchers.IO) {
+ val config = config()
+ if (config.passphrase.isBlank()) {
+ record("local", "", 0, "FAILED", "no passphrase set")
+ return@withContext Result.failure(IllegalStateException("Set a backup passphrase first"))
+ }
+ runCatching {
+ val snapshot = snapshot()
+ try {
+ val encrypted = encrypt(snapshot.readBytes(), config.passphrase)
+ val name = "lifeos-${STAMP.format(Date())}.lifeosbak"
+ val localDir = File(context.getExternalFilesDir("backups"), "").apply { mkdirs() }
+ val localFile = File(localDir, name)
+ localFile.writeBytes(encrypted)
+ trimLocal(localDir, config.keepGenerations)
+
+ var destination = localFile.absolutePath
+ if (config.webdavUrl.isNotBlank()) {
+ upload(config, name, encrypted)
+ destination = "${config.webdavUrl.trimEnd('/')}/$name"
+ }
+
+ // Verification is the whole reason this exists: decrypt what was
+ // written and check it is really a SQLite file.
+ val readBack = decrypt(localFile.readBytes(), config.passphrase)
+ val verified = readBack.size > 100 && String(readBack.copyOf(15)) == "SQLite format 3"
+ record(
+ destination = destination,
+ fileName = name,
+ size = encrypted.size.toLong(),
+ status = if (verified) "VERIFIED" else "OK",
+ detail = if (verified) "" else "written but could not be verified",
+ )
+ BackupOutcome(name, encrypted.size.toLong(), destination, verified)
+ } finally {
+ snapshot.delete()
+ }
+ }.onFailure { failure ->
+ LifeLogger.w(TAG, "Backup failed", failure)
+ record("local", "", 0, "FAILED", failure.message.orEmpty())
+ }
+ }
+
+ /**
+ * Decrypts a snapshot and stages it next to the live database.
+ *
+ * Room holds the database open, so the swap itself happens on the next
+ * process start: the staged file is applied by [applyStagedRestore] before
+ * the database is opened, which is the only safe moment.
+ */
+ suspend fun restoreFrom(file: File): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ val passphrase = config().passphrase
+ require(passphrase.isNotBlank()) { "Set the passphrase the backup was made with" }
+ val plain = decrypt(file.readBytes(), passphrase)
+ require(String(plain.copyOf(15)) == "SQLite format 3") {
+ "That file did not decrypt to a database - wrong passphrase?"
+ }
+ stagedFile().writeBytes(plain)
+ "Restore staged: ${file.name}. Close and reopen LifeOS to apply it."
+ }
+ }
+
+ /** Local snapshots available to restore from, newest first. */
+ fun localBackups(): List =
+ File(context.getExternalFilesDir("backups"), "").listFiles()
+ ?.filter { it.isFile && it.name.endsWith(".lifeosbak") }
+ ?.sortedByDescending { it.lastModified() }
+ .orEmpty()
+
+ suspend fun latestRun(): BackupRunEntity? = backupDao.latest()
+
+ suspend fun history(limit: Int = 10): List = backupDao.recent(limit)
+
+ // ---- snapshot and crypto ------------------------------------------------
+
+ /** Consistent copy of the live database; VACUUM INTO is WAL-safe. */
+ private fun snapshot(): File {
+ val target = File(context.cacheDir, "lifeos-snapshot.db")
+ target.delete()
+ database.openHelper.writableDatabase.query("VACUUM INTO ?", arrayOf(target.absolutePath)).use { it.moveToFirst() }
+ return target
+ }
+
+ private fun encrypt(plain: ByteArray, passphrase: String): ByteArray {
+ val salt = ByteArray(16).also(random::nextBytes)
+ val iv = ByteArray(12).also(random::nextBytes)
+ val key = deriveKey(passphrase, salt)
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
+ init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv))
+ }
+ val body = cipher.doFinal(plain)
+ // Header keeps the format self-describing: magic, salt, iv, ciphertext.
+ return MAGIC + salt + iv + body
+ }
+
+ private fun decrypt(blob: ByteArray, passphrase: String): ByteArray {
+ require(blob.size > MAGIC.size + 28) { "That file is too small to be a LifeOS backup" }
+ require(blob.copyOf(MAGIC.size).contentEquals(MAGIC)) { "That file is not a LifeOS backup" }
+ val salt = blob.copyOfRange(MAGIC.size, MAGIC.size + 16)
+ val iv = blob.copyOfRange(MAGIC.size + 16, MAGIC.size + 28)
+ val body = blob.copyOfRange(MAGIC.size + 28, blob.size)
+ val key = deriveKey(passphrase, salt)
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
+ init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv))
+ }
+ return cipher.doFinal(body)
+ }
+
+ /** PBKDF2-HMAC-SHA256 over raw bytes, 120k iterations. */
+ private fun deriveKey(passphrase: String, salt: ByteArray): ByteArray {
+ val mac = Mac.getInstance("HmacSHA256").apply {
+ init(SecretKeySpec(passphrase.toByteArray(), "HmacSHA256"))
+ }
+ var u = mac.doFinal(salt + byteArrayOf(0, 0, 0, 1))
+ val result = u.copyOf()
+ repeat(ITERATIONS - 1) {
+ u = mac.doFinal(u)
+ for (i in result.indices) result[i] = (result[i].toInt() xor u[i].toInt()).toByte()
+ }
+ return result
+ }
+
+ private fun upload(config: BackupConfig, name: String, bytes: ByteArray) {
+ val url = "${config.webdavUrl.trimEnd('/')}/$name"
+ val builder = Request.Builder()
+ .url(url)
+ .put(bytes.toRequestBody(OCTET))
+ if (config.webdavUser.isNotBlank()) {
+ builder.header("Authorization", Credentials.basic(config.webdavUser, config.webdavPassword))
+ }
+ client.newCall(builder.build()).execute().use { response ->
+ check(response.isSuccessful) { "WebDAV said HTTP ${response.code}" }
+ }
+ }
+
+ private fun trimLocal(dir: File, keep: Int) {
+ dir.listFiles()
+ ?.filter { it.name.endsWith(".lifeosbak") }
+ ?.sortedByDescending { it.lastModified() }
+ ?.drop(keep.coerceAtLeast(1))
+ ?.forEach { it.delete() }
+ }
+
+ private suspend fun record(destination: String, fileName: String, size: Long, status: String, detail: String) {
+ backupDao.insert(
+ BackupRunEntity(
+ at = System.currentTimeMillis(),
+ destination = destination,
+ fileName = fileName,
+ sizeBytes = size,
+ status = status,
+ detail = detail.take(160),
+ ),
+ )
+ }
+
+ private suspend fun config(): BackupConfig = BackupConfig(
+ passphrase = settingsRepository.backupPassphrase.first(),
+ webdavUrl = settingsRepository.backupWebdavUrl.first(),
+ webdavUser = settingsRepository.backupWebdavUser.first(),
+ webdavPassword = settingsRepository.backupWebdavPassword.first(),
+ keepGenerations = settingsRepository.backupKeepGenerations.first(),
+ )
+
+ companion object {
+ private const val TAG = "BackupService"
+ private const val ITERATIONS = 120_000
+ private val MAGIC = "LIFEOSBAK1".toByteArray()
+ private val OCTET = "application/octet-stream".toMediaType()
+ private val STAMP = SimpleDateFormat("yyyyMMdd-HHmm", Locale.US)
+
+ /** Where a decrypted snapshot waits for the next cold start. */
+ fun stagedFile(context: Context): File = File(context.filesDir, "restore-staged.db")
+
+ /**
+ * Applies a staged restore before Room opens the database. Called from
+ * the application's onCreate, which is the only point where swapping the
+ * file underneath Room is safe.
+ */
+ fun applyStagedRestore(context: Context, databaseName: String): Boolean {
+ val staged = stagedFile(context)
+ if (!staged.exists()) return false
+ val target = context.getDatabasePath(databaseName)
+ return runCatching {
+ target.parentFile?.mkdirs()
+ // Drop the WAL and shm so SQLite cannot mix old journal with new data.
+ File(target.parentFile, "${target.name}-wal").delete()
+ File(target.parentFile, "${target.name}-shm").delete()
+ staged.copyTo(target, overwrite = true)
+ staged.delete()
+ LifeLogger.i(TAG, "Restored database from staged snapshot")
+ true
+ }.getOrElse {
+ LifeLogger.e(TAG, "Staged restore failed", it)
+ false
+ }
+ }
+ }
+
+ private fun stagedFile(): File = stagedFile(context)
+}
+
+private data class BackupConfig(
+ val passphrase: String,
+ val webdavUrl: String,
+ val webdavUser: String,
+ val webdavPassword: String,
+ val keepGenerations: Int,
+)
diff --git a/feature/sync/src/main/kotlin/com/lifeos/feature/sync/data/SyncJarvisBridge.kt b/feature/sync/src/main/kotlin/com/lifeos/feature/sync/data/SyncJarvisBridge.kt
new file mode 100644
index 0000000..4d5da30
--- /dev/null
+++ b/feature/sync/src/main/kotlin/com/lifeos/feature/sync/data/SyncJarvisBridge.kt
@@ -0,0 +1,76 @@
+package com.lifeos.feature.sync.data
+
+import com.lifeos.core.common.result.LifeError
+import com.lifeos.core.common.result.LifeResult
+import com.lifeos.core.service.ActionEcho
+import com.lifeos.core.service.LifeAction
+import com.lifeos.core.service.LifeActionHandler
+import com.lifeos.core.service.LifeDataProvider
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import javax.inject.Inject
+
+/** Backup health as Jarvis reads it. */
+internal class SyncProvider @Inject constructor(
+ private val backupService: BackupService,
+) : LifeDataProvider {
+
+ override val topic: String = "backups"
+ override val description: String = "when LifeOS was last backed up and whether it verified"
+
+ override suspend fun read(query: String?): String {
+ val runs = backupService.history(6)
+ if (runs.isEmpty()) return "No backup has ever run. Set a passphrase in Sync and back up once."
+ return buildString {
+ val latest = runs.first()
+ appendLine(
+ "Last backup ${AT.format(Date(latest.at))}: ${latest.status}" +
+ if (latest.detail.isBlank()) "" else " (${latest.detail})",
+ )
+ appendLine("File ${latest.fileName}, ${latest.sizeBytes / 1024} KB, to ${latest.destination}")
+ appendLine("Local snapshots kept: ${backupService.localBackups().size}")
+ appendLine("History:")
+ runs.forEach { appendLine("- ${AT.format(Date(it.at))} ${it.status} ${it.fileName}") }
+ }.trim()
+ }
+
+ private companion object {
+ val AT = SimpleDateFormat("d MMM HH:mm", Locale.getDefault())
+ }
+}
+
+internal class SyncActionHandler @Inject constructor(
+ private val backupService: BackupService,
+ private val echo: ActionEcho,
+) : LifeActionHandler {
+
+ override fun canHandle(action: LifeAction): Boolean = action is LifeAction.BackupNow
+
+ override suspend fun execute(action: LifeAction): LifeResult =
+ backupService.backupNow().fold(
+ onSuccess = { outcome ->
+ echo.fileName(outcome.fileName)
+ LifeResult.Success(outcome.sizeBytes)
+ },
+ onFailure = { LifeResult.Failure(LifeError.Unknown(it.message ?: "Backup failed")) },
+ )
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal abstract class SyncJarvisModule {
+
+ @Binds
+ @IntoSet
+ abstract fun bindProvider(impl: SyncProvider): LifeDataProvider
+
+ @Binds
+ @IntoSet
+ abstract fun bindHandler(impl: SyncActionHandler): LifeActionHandler
+}
diff --git a/feature/triggers/build.gradle.kts b/feature/triggers/build.gradle.kts
new file mode 100644
index 0000000..f71a955
--- /dev/null
+++ b/feature/triggers/build.gradle.kts
@@ -0,0 +1,20 @@
+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.service)
+ implementation(projects.core.places)
+ implementation(projects.core.designsystem)
+ implementation(projects.core.database)
+ 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/triggers/src/main/AndroidManifest.xml b/feature/triggers/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a169a56
--- /dev/null
+++ b/feature/triggers/src/main/AndroidManifest.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
diff --git a/feature/triggers/src/main/kotlin/com/lifeos/feature/triggers/TriggersScreen.kt b/feature/triggers/src/main/kotlin/com/lifeos/feature/triggers/TriggersScreen.kt
new file mode 100644
index 0000000..38dc295
--- /dev/null
+++ b/feature/triggers/src/main/kotlin/com/lifeos/feature/triggers/TriggersScreen.kt
@@ -0,0 +1,507 @@
+package com.lifeos.feature.triggers
+
+import androidx.compose.foundation.layout.Arrangement
+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.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.PlayArrow
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FloatingActionButton
+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.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.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.lifeos.core.database.triggers.TriggerRuleEntity
+import com.lifeos.core.designsystem.component.EmptyState
+import com.lifeos.core.designsystem.component.FadeThrough
+import com.lifeos.feature.triggers.data.TriggerArg
+import com.lifeos.feature.triggers.data.TriggerCatalog
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/**
+ * Triggers (§Module Triggers): when this happens, do that. Every rule is visible,
+ * editable, runnable by hand, and every fire is in the log underneath.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TriggersRoute(viewModel: TriggersViewModel = hiltViewModel()) {
+ val state by viewModel.uiState.collectAsState()
+ val snackbarHostState = remember { SnackbarHostState() }
+ LaunchedEffect(state.message) {
+ state.message?.let {
+ snackbarHostState.showSnackbar(it)
+ viewModel.dismissMessage()
+ }
+ }
+
+ FadeThrough(
+ targetState = when {
+ state.draft != null -> "rule"
+ state.placeDraft != null -> "place"
+ state.tab == 1 -> "places"
+ else -> "rules"
+ },
+ label = "triggers-screen",
+ ) { screen ->
+ when (screen) {
+ "rule" -> RuleEditor(state = state, viewModel = viewModel)
+ "place" -> PlaceEditor(state = state, viewModel = viewModel)
+ "places" -> PlaceList(state = state, viewModel = viewModel, snackbarHostState = snackbarHostState)
+ else -> RuleList(state = state, viewModel = viewModel, snackbarHostState = snackbarHostState)
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun RuleList(
+ state: TriggersUiState,
+ viewModel: TriggersViewModel,
+ snackbarHostState: SnackbarHostState,
+) {
+ Scaffold(
+ topBar = { TopAppBar(title = { Text("Triggers") }) },
+ floatingActionButton = {
+ FloatingActionButton(onClick = viewModel::newRule) {
+ Icon(Icons.Filled.Add, contentDescription = "New rule")
+ }
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().padding(padding),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ item { TriggerTabs(state = state, viewModel = viewModel) }
+ if (state.rules.isEmpty()) {
+ item {
+ EmptyState(
+ title = "No rules yet",
+ description = "A rule is one trigger and one action: arrive home, start a Brick mode; " +
+ "22:00 on weekdays, start Wind-down; a parcel notification, add a task.",
+ )
+ }
+ }
+ items(state.rules, key = { it.id }) { rule ->
+ RuleCard(rule = rule, viewModel = viewModel)
+ }
+ if (state.fires.isNotEmpty()) {
+ item {
+ Text(
+ "Fire log",
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ }
+ items(state.fires, key = { "fire-${it.id}" }) { fire ->
+ ListItem(
+ headlineContent = { Text(fire.ruleName) },
+ supportingContent = {
+ Text(
+ listOf(fire.outcome, fire.detail).filter { it.isNotBlank() }.joinToString(" - "),
+ )
+ },
+ trailingContent = {
+ Text(AT.format(Date(fire.at)), style = MaterialTheme.typography.labelSmall)
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RuleCard(rule: TriggerRuleEntity, viewModel: TriggersViewModel) {
+ Card {
+ Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(rule.name, style = MaterialTheme.typography.titleMedium)
+ Text(
+ buildString {
+ append("when ${TriggerCatalog.triggerByType[rule.triggerType]?.label ?: rule.triggerType}")
+ if (rule.triggerArg.isNotBlank()) append(" ${rule.triggerArg}")
+ append(" -> ${TriggerCatalog.actionByType[rule.actionType]?.label ?: rule.actionType}")
+ if (rule.actionArg.isNotBlank()) append(" ${rule.actionArg}")
+ if (rule.days.isNotBlank()) append(" - days ${rule.days}")
+ if (rule.fireCount > 0) append(" - fired ${rule.fireCount}x")
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Switch(checked = rule.enabled, onCheckedChange = { viewModel.toggle(rule) })
+ IconButton(onClick = { viewModel.runNow(rule) }) {
+ Icon(Icons.Filled.PlayArrow, contentDescription = "Run now")
+ }
+ IconButton(onClick = { viewModel.editRule(rule) }) {
+ Icon(Icons.Filled.Edit, contentDescription = "Edit")
+ }
+ IconButton(onClick = { viewModel.delete(rule.id) }) {
+ Icon(Icons.Filled.Delete, contentDescription = "Delete")
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun RuleEditor(state: TriggersUiState, viewModel: TriggersViewModel) {
+ val draft = state.draft ?: return
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(if (draft.id == 0L) "New rule" else "Edit rule") },
+ navigationIcon = {
+ IconButton(onClick = viewModel::closeEditor) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = { TextButton(onClick = viewModel::saveDraft) { Text("Save") } },
+ )
+ },
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().padding(padding),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ item {
+ OutlinedTextField(
+ value = draft.name,
+ onValueChange = viewModel::onName,
+ label = { Text("Rule name") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ item { Text("When", style = MaterialTheme.typography.titleSmall) }
+ item {
+ ChipFlow(
+ options = TriggerCatalog.triggers.map { it.type to it.label },
+ selected = draft.triggerType,
+ onSelect = viewModel::onTriggerType,
+ )
+ }
+ TriggerCatalog.triggerByType[draft.triggerType]?.let { spec ->
+ if (spec.arg != TriggerArg.NONE) {
+ item {
+ ArgField(
+ arg = spec.arg,
+ hint = spec.hint,
+ value = draft.triggerArg,
+ places = state.places,
+ onChange = viewModel::onTriggerArg,
+ )
+ }
+ }
+ }
+ item { Text("Then", style = MaterialTheme.typography.titleSmall) }
+ item {
+ ChipFlow(
+ options = TriggerCatalog.actions.map { it.type to it.label },
+ selected = draft.actionType,
+ onSelect = viewModel::onActionType,
+ )
+ }
+ TriggerCatalog.actionByType[draft.actionType]?.let { spec ->
+ if (spec.arg != TriggerArg.NONE) {
+ item {
+ ArgField(
+ arg = spec.arg,
+ hint = spec.hint,
+ value = draft.actionArg,
+ places = state.places,
+ onChange = viewModel::onActionArg,
+ )
+ }
+ }
+ }
+ item { Text("Only on these days", style = MaterialTheme.typography.titleSmall) }
+ item {
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ listOf("Mon" to 1, "Tue" to 2, "Wed" to 3, "Thu" to 4, "Fri" to 5, "Sat" to 6, "Sun" to 7)
+ .forEach { (label, day) ->
+ FilterChip(
+ selected = draft.days.contains(day),
+ onClick = { viewModel.toggleDay(day) },
+ label = { Text(label) },
+ )
+ }
+ }
+ }
+ item {
+ Text(
+ "Leave the days empty for every day. Rules fire at most once a minute, and every fire is " +
+ "written to the log so you can see why something happened.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ item {
+ OutlinedButton(onClick = viewModel::testDraft) { Text("Save and run once") }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ChipFlow(options: List>, selected: String, onSelect: (String) -> Unit) {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ options.chunked(2).forEach { row ->
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ row.forEach { (value, label) ->
+ FilterChip(
+ selected = selected == value,
+ onClick = { onSelect(value) },
+ label = { Text(label) },
+ modifier = Modifier.weight(1f),
+ )
+ }
+ if (row.size == 1) androidx.compose.foundation.layout.Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+ }
+}
+
+@Composable
+private fun ArgField(
+ arg: TriggerArg,
+ hint: String,
+ value: String,
+ places: List>,
+ onChange: (String) -> Unit,
+) {
+ when (arg) {
+ TriggerArg.PLACE -> Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ if (places.isEmpty()) {
+ Text(
+ "No places saved yet - add one in Places first.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ places.chunked(2).forEach { row ->
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ row.forEach { (id, name) ->
+ FilterChip(
+ selected = value == id.toString(),
+ onClick = { onChange(id.toString()) },
+ label = { Text(name) },
+ )
+ }
+ }
+ }
+ }
+
+ TriggerArg.MINUTE_OF_DAY -> OutlinedTextField(
+ value = value,
+ onValueChange = { input -> onChange(input.filter { it.isDigit() || it == ':' }) },
+ label = { Text("Time, e.g. 22:00") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ TriggerArg.NUMBER, TriggerArg.DURATION -> OutlinedTextField(
+ value = value,
+ onValueChange = { input -> onChange(input.filter { it.isDigit() }) },
+ label = { Text(hint.ifBlank { "Number" }) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ else -> OutlinedTextField(
+ value = value,
+ onValueChange = onChange,
+ label = { Text(hint.ifBlank { "Value" }) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+}
+
+private val AT = SimpleDateFormat("EEE HH:mm", Locale.getDefault())
+
+@Composable
+private fun TriggerTabs(state: TriggersUiState, viewModel: TriggersViewModel) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ listOf("Rules", "Places").forEachIndexed { index, label ->
+ FilterChip(
+ selected = state.tab == index,
+ onClick = { viewModel.selectTab(index) },
+ label = { Text(label) },
+ )
+ }
+ state.here?.let {
+ Text(
+ " at $it",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.align(Alignment.CenterVertically),
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun PlaceList(
+ state: TriggersUiState,
+ viewModel: TriggersViewModel,
+ snackbarHostState: SnackbarHostState,
+) {
+ Scaffold(
+ topBar = { TopAppBar(title = { Text("Places") }) },
+ floatingActionButton = {
+ FloatingActionButton(onClick = viewModel::newPlace) {
+ Icon(Icons.Filled.Add, contentDescription = "New place")
+ }
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().padding(padding),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ item { TriggerTabs(state = state, viewModel = viewModel) }
+ if (state.placeRows.isEmpty()) {
+ item {
+ EmptyState(
+ title = "No places yet",
+ description = "A place is a Wi-Fi name, a coordinate with a radius, or both. " +
+ "Android has no geofence API without Play Services, so LifeOS matches these " +
+ "cheaply instead - Wi-Fi is the most reliable indoors.",
+ )
+ }
+ }
+ items(state.placeRows, key = { it.id }) { place ->
+ Card {
+ Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(place.name, style = MaterialTheme.typography.titleMedium)
+ Text(
+ buildString {
+ place.wifiSsid?.let { append("wifi $it") }
+ if (place.latitude != null && place.longitude != null) {
+ if (isNotEmpty()) append(" - ")
+ append("${place.latitude}, ${place.longitude} (${place.radiusMeters}m)")
+ }
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ IconButton(onClick = { viewModel.editPlace(place) }) {
+ Icon(Icons.Filled.Edit, contentDescription = "Edit")
+ }
+ IconButton(onClick = { viewModel.deletePlace(place.id) }) {
+ Icon(Icons.Filled.Delete, contentDescription = "Delete")
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun PlaceEditor(state: TriggersUiState, viewModel: TriggersViewModel) {
+ val draft = state.placeDraft ?: return
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(if (draft.id == 0L) "New place" else "Edit place") },
+ navigationIcon = {
+ IconButton(onClick = viewModel::closePlaceEditor) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = { TextButton(onClick = viewModel::savePlace) { Text("Save") } },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ OutlinedTextField(
+ value = draft.name,
+ onValueChange = { value -> viewModel.updatePlaceDraft { it.copy(name = value) } },
+ label = { Text("Name, e.g. Home") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedTextField(
+ value = draft.wifiSsid,
+ onValueChange = { value -> viewModel.updatePlaceDraft { it.copy(wifiSsid = value) } },
+ label = { Text("Wi-Fi network (most reliable)") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(
+ value = draft.latitude,
+ onValueChange = { value -> viewModel.updatePlaceDraft { it.copy(latitude = value) } },
+ label = { Text("Latitude") },
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ )
+ OutlinedTextField(
+ value = draft.longitude,
+ onValueChange = { value -> viewModel.updatePlaceDraft { it.copy(longitude = value) } },
+ label = { Text("Longitude") },
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ OutlinedTextField(
+ value = draft.radiusMeters,
+ onValueChange = { value -> viewModel.updatePlaceDraft { it.copy(radiusMeters = value) } },
+ label = { Text("Radius in metres") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedButton(onClick = viewModel::useHere) { Text("Use where I am now") }
+ Text(
+ "Matching runs every two minutes and never asks for an active GPS fix, so it costs " +
+ "almost nothing on battery.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
diff --git a/feature/triggers/src/main/kotlin/com/lifeos/feature/triggers/TriggersViewModel.kt b/feature/triggers/src/main/kotlin/com/lifeos/feature/triggers/TriggersViewModel.kt
new file mode 100644
index 0000000..54a53af
--- /dev/null
+++ b/feature/triggers/src/main/kotlin/com/lifeos/feature/triggers/TriggersViewModel.kt
@@ -0,0 +1,290 @@
+package com.lifeos.feature.triggers
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.lifeos.core.database.places.PlaceDao
+import com.lifeos.core.database.triggers.TriggerDao
+import com.lifeos.core.database.triggers.TriggerFireEntity
+import com.lifeos.core.database.triggers.TriggerRuleEntity
+import com.lifeos.feature.triggers.data.TriggerCatalog
+import com.lifeos.feature.triggers.data.TriggerEngine
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/** A rule being written; days are kept as a set so chips are trivial. */
+data class RuleDraft(
+ val id: Long = 0,
+ val name: String = "",
+ val triggerType: String = "TIME",
+ val triggerArg: String = "",
+ val actionType: String = "TASK",
+ val actionArg: String = "",
+ val days: Set = emptySet(),
+)
+
+/** A place being added or edited from the Places tab. */
+data class PlaceDraft(
+ val id: Long = 0,
+ val name: String = "",
+ val wifiSsid: String = "",
+ val latitude: String = "",
+ val longitude: String = "",
+ val radiusMeters: String = "150",
+)
+
+data class TriggersUiState(
+ val tab: Int = 0,
+ val placeRows: List = emptyList(),
+ val placeDraft: PlaceDraft? = null,
+ val here: String? = null,
+ val rules: List = emptyList(),
+ val fires: List = emptyList(),
+ val places: List> = emptyList(),
+ val draft: RuleDraft? = null,
+ val message: String? = null,
+)
+
+@HiltViewModel
+class TriggersViewModel @Inject constructor(
+ private val triggerDao: TriggerDao,
+ private val placeDao: PlaceDao,
+ private val engine: TriggerEngine,
+ private val placeEngine: com.lifeos.core.places.PlaceEngine,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(TriggersUiState())
+ val uiState = _uiState.asStateFlow()
+
+ init {
+ engine.start()
+ viewModelScope.launch {
+ triggerDao.observeRules().collect { rules -> _uiState.value = _uiState.value.copy(rules = rules) }
+ }
+ viewModelScope.launch {
+ triggerDao.observeFires().collect { fires -> _uiState.value = _uiState.value.copy(fires = fires) }
+ }
+ viewModelScope.launch {
+ placeDao.observeAll().collect { places ->
+ _uiState.value = _uiState.value.copy(
+ places = places.map { it.id to it.name },
+ placeRows = places,
+ )
+ }
+ }
+ viewModelScope.launch {
+ placeEngine.current.collect { place ->
+ _uiState.value = _uiState.value.copy(here = place?.name)
+ }
+ }
+ }
+
+ fun selectTab(index: Int) { _uiState.value = _uiState.value.copy(tab = index) }
+
+ fun newRule() { _uiState.value = _uiState.value.copy(draft = RuleDraft()) }
+
+ // ---- places ------------------------------------------------------------
+
+ fun newPlace() { _uiState.value = _uiState.value.copy(placeDraft = PlaceDraft()) }
+
+ fun editPlace(place: com.lifeos.core.database.places.PlaceEntity) {
+ _uiState.value = _uiState.value.copy(
+ placeDraft = PlaceDraft(
+ id = place.id,
+ name = place.name,
+ wifiSsid = place.wifiSsid.orEmpty(),
+ latitude = place.latitude?.toString().orEmpty(),
+ longitude = place.longitude?.toString().orEmpty(),
+ radiusMeters = place.radiusMeters.toString(),
+ ),
+ )
+ }
+
+ fun closePlaceEditor() { _uiState.value = _uiState.value.copy(placeDraft = null) }
+
+ fun updatePlaceDraft(transform: (PlaceDraft) -> PlaceDraft) {
+ _uiState.value = _uiState.value.copy(placeDraft = _uiState.value.placeDraft?.let(transform))
+ }
+
+ /** Fills the draft from where the phone is right now. */
+ fun useHere() {
+ val fix = placeEngine.lastKnownLocation()
+ val ssid = placeEngine.currentSsid()
+ updatePlaceDraft { draft ->
+ draft.copy(
+ latitude = fix?.latitude?.toString() ?: draft.latitude,
+ longitude = fix?.longitude?.toString() ?: draft.longitude,
+ wifiSsid = ssid ?: draft.wifiSsid,
+ )
+ }
+ if (fix == null && ssid == null) {
+ _uiState.value = _uiState.value.copy(
+ message = "No location or Wi-Fi signal yet - grant location and try again",
+ )
+ }
+ }
+
+ fun savePlace() {
+ val draft = _uiState.value.placeDraft ?: return
+ if (draft.name.isBlank()) {
+ _uiState.value = _uiState.value.copy(message = "Give the place a name")
+ return
+ }
+ val latitude = draft.latitude.toDoubleOrNull()
+ val longitude = draft.longitude.toDoubleOrNull()
+ if (latitude == null && draft.wifiSsid.isBlank()) {
+ _uiState.value = _uiState.value.copy(
+ message = "A place needs coordinates or a Wi-Fi name to be recognised",
+ )
+ return
+ }
+ viewModelScope.launch {
+ val row = com.lifeos.core.database.places.PlaceEntity(
+ id = draft.id,
+ name = draft.name.trim().take(40),
+ latitude = latitude,
+ longitude = longitude,
+ radiusMeters = draft.radiusMeters.filter { it.isDigit() }.toIntOrNull()?.coerceIn(30, 5_000) ?: 150,
+ wifiSsid = draft.wifiSsid.trim().ifBlank { null },
+ createdAt = System.currentTimeMillis(),
+ )
+ if (draft.id == 0L) placeDao.insert(row) else placeDao.update(row)
+ placeEngine.refresh()
+ _uiState.value = _uiState.value.copy(placeDraft = null, message = "Place saved")
+ }
+ }
+
+ fun deletePlace(id: Long) {
+ viewModelScope.launch {
+ placeDao.delete(id)
+ placeEngine.refresh()
+ }
+ }
+
+ fun editRule(rule: TriggerRuleEntity) {
+ _uiState.value = _uiState.value.copy(
+ draft = RuleDraft(
+ id = rule.id,
+ name = rule.name,
+ triggerType = rule.triggerType,
+ triggerArg = rule.triggerArg,
+ actionType = rule.actionType,
+ actionArg = rule.actionArg,
+ days = rule.days.split(',').mapNotNull { it.trim().toIntOrNull() }.toSet(),
+ ),
+ )
+ }
+
+ fun closeEditor() { _uiState.value = _uiState.value.copy(draft = null) }
+ fun dismissMessage() { _uiState.value = _uiState.value.copy(message = null) }
+
+ fun onName(value: String) = updateDraft { it.copy(name = value) }
+ fun onTriggerType(value: String) = updateDraft { it.copy(triggerType = value, triggerArg = "") }
+ fun onTriggerArg(value: String) = updateDraft { it.copy(triggerArg = value) }
+ fun onActionType(value: String) = updateDraft { it.copy(actionType = value, actionArg = "") }
+ fun onActionArg(value: String) = updateDraft { it.copy(actionArg = value) }
+
+ fun toggleDay(day: Int) = updateDraft { draft ->
+ draft.copy(days = if (day in draft.days) draft.days - day else draft.days + day)
+ }
+
+ fun saveDraft(runAfterSave: Boolean = false) {
+ val draft = _uiState.value.draft ?: return
+ val triggerSpec = TriggerCatalog.triggerByType[draft.triggerType]
+ val actionSpec = TriggerCatalog.actionByType[draft.actionType]
+ if (triggerSpec == null || actionSpec == null) {
+ _uiState.value = _uiState.value.copy(message = "Pick a trigger and an action")
+ return
+ }
+ val triggerArg = normalizeArg(draft.triggerType, draft.triggerArg)
+ if (triggerSpec.arg != com.lifeos.feature.triggers.data.TriggerArg.NONE && triggerArg.isBlank()) {
+ _uiState.value = _uiState.value.copy(message = "\"${triggerSpec.label}\" still needs a value")
+ return
+ }
+ if (actionSpec.arg != com.lifeos.feature.triggers.data.TriggerArg.NONE && draft.actionArg.isBlank()) {
+ _uiState.value = _uiState.value.copy(message = "\"${actionSpec.label}\" still needs a value")
+ return
+ }
+ viewModelScope.launch {
+ val row = TriggerRuleEntity(
+ id = draft.id,
+ name = draft.name.trim().ifBlank { "${triggerSpec.label} -> ${actionSpec.label}" }.take(60),
+ triggerType = draft.triggerType,
+ triggerArg = triggerArg,
+ days = draft.days.sorted().joinToString(","),
+ actionType = draft.actionType,
+ actionArg = draft.actionArg.trim(),
+ createdAt = System.currentTimeMillis(),
+ )
+ val saved = if (draft.id == 0L) {
+ val id = triggerDao.insertRule(row)
+ triggerDao.rule(id)
+ } else {
+ val existing = triggerDao.rule(draft.id)
+ if (existing != null) {
+ triggerDao.updateRule(
+ existing.copy(
+ name = row.name,
+ triggerType = row.triggerType,
+ triggerArg = row.triggerArg,
+ days = row.days,
+ actionType = row.actionType,
+ actionArg = row.actionArg,
+ ),
+ )
+ }
+ triggerDao.rule(draft.id)
+ }
+ engine.rearmAll()
+ val message = if (runAfterSave && saved != null) engine.run(saved, byHand = true) else "Rule saved"
+ _uiState.value = _uiState.value.copy(draft = null, message = message)
+ }
+ }
+
+ /** Save, then run once so the rule can be checked without waiting for it. */
+ fun testDraft() = saveDraft(runAfterSave = true)
+
+ fun toggle(rule: TriggerRuleEntity) {
+ viewModelScope.launch {
+ triggerDao.updateRule(rule.copy(enabled = !rule.enabled))
+ engine.rearmAll()
+ }
+ }
+
+ fun runNow(rule: TriggerRuleEntity) {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(message = engine.run(rule, byHand = true))
+ }
+ }
+
+ fun delete(id: Long) {
+ viewModelScope.launch {
+ triggerDao.deleteRule(id)
+ engine.rearmAll()
+ }
+ }
+
+ /** "22:00" and "2200" both mean minute 1320. */
+ private fun normalizeArg(triggerType: String, raw: String): String {
+ if (triggerType != "TIME") return raw.trim()
+ val digits = raw.filter { it.isDigit() }
+ if (raw.contains(':')) {
+ val hours = raw.substringBefore(':').filter { it.isDigit() }.toIntOrNull() ?: return ""
+ val minutes = raw.substringAfter(':').filter { it.isDigit() }.toIntOrNull() ?: 0
+ return (hours * 60 + minutes).coerceIn(0, 1439).toString()
+ }
+ val value = digits.toIntOrNull() ?: return ""
+ return if (value > 1439) {
+ ((value / 100) * 60 + value % 100).coerceIn(0, 1439).toString()
+ } else {
+ value.toString()
+ }
+ }
+
+ private fun updateDraft(transform: (RuleDraft) -> RuleDraft) {
+ _uiState.value = _uiState.value.copy(draft = _uiState.value.draft?.let(transform))
+ }
+}
diff --git a/feature/triggers/src/main/kotlin/com/lifeos/feature/triggers/data/TriggerCatalog.kt b/feature/triggers/src/main/kotlin/com/lifeos/feature/triggers/data/TriggerCatalog.kt
new file mode 100644
index 0000000..c692828
--- /dev/null
+++ b/feature/triggers/src/main/kotlin/com/lifeos/feature/triggers/data/TriggerCatalog.kt
@@ -0,0 +1,64 @@
+package com.lifeos.feature.triggers.data
+
+/** What a trigger or action needs from the user, so the editor can be generated. */
+enum class TriggerArg { NONE, MINUTE_OF_DAY, PLACE, TEXT, NUMBER, DURATION }
+
+data class TriggerTypeSpec(
+ val type: String,
+ val label: String,
+ val arg: TriggerArg,
+ val hint: String,
+)
+
+data class ActionTypeSpec(
+ val type: String,
+ val label: String,
+ val arg: TriggerArg,
+ val hint: String,
+)
+
+/**
+ * The rule vocabulary (§Module Triggers).
+ *
+ * Every entry here is implemented by [TriggerEngine], so a rule the editor can
+ * build is a rule that can actually fire - and Jarvis reads the same list, which
+ * is why he cannot invent a trigger that does nothing.
+ */
+object TriggerCatalog {
+
+ val triggers: List = listOf(
+ TriggerTypeSpec("TIME", "At a time", TriggerArg.MINUTE_OF_DAY, "Fires once a day at this time"),
+ TriggerTypeSpec("PLACE_ENTER", "When I arrive", TriggerArg.PLACE, "A saved place"),
+ TriggerTypeSpec("PLACE_LEAVE", "When I leave", TriggerArg.PLACE, "A saved place"),
+ TriggerTypeSpec("WIFI", "When Wi-Fi connects", TriggerArg.TEXT, "Network name"),
+ TriggerTypeSpec("NFC_TAG", "When a tag is tapped", TriggerArg.TEXT, "Tag id, or blank for any"),
+ TriggerTypeSpec("SIGNAL", "On a notification", TriggerArg.TEXT, "App name or keyword to match"),
+ TriggerTypeSpec("SCREEN_TIME", "Screen time over", TriggerArg.NUMBER, "Minutes used today"),
+ TriggerTypeSpec("BATTERY_BELOW", "Battery below", TriggerArg.NUMBER, "Percent"),
+ TriggerTypeSpec("REMINDER_FIRED", "When a reminder fires", TriggerArg.TEXT, "Title contains, or blank for any"),
+ )
+
+ val actions: List = listOf(
+ ActionTypeSpec("TASK", "Add a task", TriggerArg.TEXT, "Task title"),
+ ActionTypeSpec("NOTE", "Add a note", TriggerArg.TEXT, "Title | body"),
+ ActionTypeSpec("REMINDER", "Set a reminder", TriggerArg.TEXT, "Title (fires in 10 minutes)"),
+ ActionTypeSpec("TIMER", "Start a timer", TriggerArg.DURATION, "Minutes"),
+ ActionTypeSpec("FOCUS", "Start a focus session", TriggerArg.DURATION, "Minutes"),
+ ActionTypeSpec("BRICK_ON", "Start a Brick mode", TriggerArg.TEXT, "Mode name"),
+ ActionTypeSpec("BRICK_OFF", "End the Brick mode", TriggerArg.NONE, ""),
+ ActionTypeSpec("MACRO", "Run a macro", TriggerArg.TEXT, "Macro name"),
+ ActionTypeSpec("PASTE", "Create a paste", TriggerArg.TEXT, "Title | text"),
+ ActionTypeSpec("DOWNLOAD", "Queue a download", TriggerArg.TEXT, "URL"),
+ ActionTypeSpec("SCREEN_TIME_EXPORT", "Export screen time", TriggerArg.TEXT, "json, csv_days or csv_apps"),
+ ActionTypeSpec("WATER_PLANT", "Mark a plant watered", TriggerArg.TEXT, "Plant name"),
+ ActionTypeSpec("SYNC_SCREEN_TIME", "Sync screen time", TriggerArg.NONE, ""),
+ )
+
+ val triggerByType = triggers.associateBy { it.type }
+ val actionByType = actions.associateBy { it.type }
+
+ /** Compact vocabulary for Jarvis, so he writes rules the engine accepts. */
+ val promptVocabulary: String =
+ "triggers: " + triggers.joinToString(", ") { it.type } +
+ "; actions: " + actions.joinToString(", ") { it.type }
+}
diff --git a/feature/triggers/src/main/kotlin/com/lifeos/feature/triggers/data/TriggerEngine.kt b/feature/triggers/src/main/kotlin/com/lifeos/feature/triggers/data/TriggerEngine.kt
new file mode 100644
index 0000000..e358fad
--- /dev/null
+++ b/feature/triggers/src/main/kotlin/com/lifeos/feature/triggers/data/TriggerEngine.kt
@@ -0,0 +1,315 @@
+package com.lifeos.feature.triggers.data
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.BatteryManager
+import com.lifeos.core.common.log.LifeLogger
+import com.lifeos.core.common.result.LifeResult
+import com.lifeos.core.database.triggers.TriggerDao
+import com.lifeos.core.database.triggers.TriggerFireEntity
+import com.lifeos.core.database.triggers.TriggerRuleEntity
+import com.lifeos.core.model.LifeModule
+import com.lifeos.core.model.SourceRef
+import com.lifeos.core.places.PlaceEngine
+import com.lifeos.core.service.LifeAction
+import com.lifeos.core.service.LifeActionDispatcher
+import com.lifeos.core.service.LifeEvent
+import com.lifeos.core.service.LifeEventBus
+import dagger.hilt.android.AndroidEntryPoint
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import java.util.Calendar
+import javax.inject.Inject
+import javax.inject.Provider
+import javax.inject.Singleton
+
+/**
+ * The automation engine (§Module Triggers).
+ *
+ * Rules are evaluated from three sources: the event bus (places, notifications,
+ * reminders, NFC), a per-rule alarm for time rules, and a poll for the state-ish
+ * triggers (screen time, battery) that have no event. Every fire is written to
+ * the audit table, successful or not, so "why did that happen?" always has an
+ * answer.
+ *
+ * Actions go through [LifeActionDispatcher], which means a rule can do anything
+ * Jarvis can do, and nothing a module has not explicitly exposed.
+ */
+@Singleton
+class TriggerEngine @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val triggerDao: TriggerDao,
+ /** Lazily: rule actions are dispatched, and one handler is the rules module. */
+ private val dispatcherProvider: Provider,
+ private val eventBus: LifeEventBus,
+ private val placeEngine: PlaceEngine,
+) {
+
+ private val dispatcher: LifeActionDispatcher get() = dispatcherProvider.get()
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ private var started = false
+
+ /** Starts listening; safe to call more than once. */
+ fun start() {
+ if (started) return
+ started = true
+ placeEngine.start()
+ scope.launch {
+ eventBus.events.collect { event -> onEvent(event) }
+ }
+ scope.launch { rearmAll() }
+ }
+
+ private suspend fun onEvent(event: LifeEvent) {
+ when (event) {
+ is LifeEvent.PlaceEntered -> fireMatching("PLACE_ENTER") { rule ->
+ rule.triggerArg.isBlank() || rule.triggerArg == event.placeId.toString() ||
+ rule.triggerArg.equals(event.name, ignoreCase = true)
+ }
+
+ is LifeEvent.PlaceLeft -> fireMatching("PLACE_LEAVE") { rule ->
+ rule.triggerArg.isBlank() || rule.triggerArg == event.placeId.toString() ||
+ rule.triggerArg.equals(event.name, ignoreCase = true)
+ }
+
+ is LifeEvent.SignalCaptured -> fireMatching("SIGNAL") { rule ->
+ val needle = rule.triggerArg.trim()
+ needle.isBlank() ||
+ event.appLabel.contains(needle, ignoreCase = true) ||
+ event.appPackage.contains(needle, ignoreCase = true) ||
+ event.title.contains(needle, ignoreCase = true) ||
+ event.text.contains(needle, ignoreCase = true)
+ }
+
+ is LifeEvent.ReminderFired -> fireMatching("REMINDER_FIRED") { rule ->
+ rule.triggerArg.isBlank() || event.title.contains(rule.triggerArg, ignoreCase = true)
+ }
+
+ is LifeEvent.ScreenTimeCrossed -> fireMatching("SCREEN_TIME") { rule ->
+ val threshold = rule.triggerArg.filter { it.isDigit() }.toIntOrNull() ?: return@fireMatching false
+ event.minutesToday >= threshold
+ }
+
+ else -> Unit
+ }
+ }
+
+ /** Called by the NFC path so a tag can drive rules as well as Brick. */
+ fun onTagScanned(tagId: String) {
+ scope.launch {
+ fireMatching("NFC_TAG") { rule ->
+ rule.triggerArg.isBlank() || rule.triggerArg.trim().equals(tagId.trim(), ignoreCase = true)
+ }
+ }
+ }
+
+ /** Wi-Fi and battery have no event of their own; the poller drives them. */
+ suspend fun poll() {
+ val ssid = placeEngine.currentSsid()
+ if (ssid != null) {
+ fireMatching("WIFI") { rule -> rule.triggerArg.trim().equals(ssid, ignoreCase = true) }
+ }
+ val battery = batteryPercent()
+ if (battery != null) {
+ fireMatching("BATTERY_BELOW") { rule ->
+ val threshold = rule.triggerArg.filter { it.isDigit() }.toIntOrNull() ?: return@fireMatching false
+ battery <= threshold
+ }
+ }
+ }
+
+ private suspend fun fireMatching(type: String, matches: (TriggerRuleEntity) -> Boolean) {
+ val rules = triggerDao.enabledRules().filter { it.triggerType == type && matches(it) }
+ rules.forEach { rule ->
+ if (!withinSchedule(rule)) return@forEach
+ // A rule that already fired inside the cooldown is skipped, which is
+ // what stops a chatty notification app from running a macro 40 times.
+ val lastFired = rule.lastFiredAt
+ if (lastFired != null && System.currentTimeMillis() - lastFired < COOLDOWN_MS) {
+ return@forEach
+ }
+ run(rule, byHand = false)
+ }
+ }
+
+ /** Runs a rule now. Used by the alarm, the event paths and the Run button. */
+ suspend fun run(rule: TriggerRuleEntity, byHand: Boolean): String {
+ val action = actionFor(rule)
+ if (action == null) {
+ record(rule, "SKIPPED", "action \"${rule.actionType}\" needs a value")
+ return "\"${rule.name}\" needs a value for its action"
+ }
+ val result = dispatcher.dispatch(action)
+ val outcome = when (result) {
+ is LifeResult.Success -> if (byHand) "RAN_BY_HAND" else "FIRED"
+ is LifeResult.Failure -> "FAILED"
+ }
+ val detail = (result as? LifeResult.Failure)?.error?.message.orEmpty()
+ record(rule, outcome, detail)
+ triggerDao.updateRule(
+ rule.copy(lastFiredAt = System.currentTimeMillis(), fireCount = rule.fireCount + 1),
+ )
+ LifeLogger.i(TAG, "Rule \"${rule.name}\" -> $outcome $detail")
+ return if (detail.isBlank()) "\"${rule.name}\" ran" else "\"${rule.name}\" failed: $detail"
+ }
+
+ private suspend fun record(rule: TriggerRuleEntity, outcome: String, detail: String) {
+ triggerDao.insertFire(
+ TriggerFireEntity(
+ ruleId = rule.id,
+ ruleName = rule.name,
+ at = System.currentTimeMillis(),
+ outcome = outcome,
+ detail = detail.take(140),
+ ),
+ )
+ triggerDao.trimFires(System.currentTimeMillis() - 30L * 86_400_000L)
+ }
+
+ /** Maps a stored rule onto the cross-module action contract. */
+ private fun actionFor(rule: TriggerRuleEntity): LifeAction? {
+ val arg = rule.actionArg.trim()
+ val minutes = arg.filter { it.isDigit() }.toIntOrNull()
+ return when (rule.actionType) {
+ "TASK" -> arg.ifBlank { null }?.let { LifeAction.CreateTask(it.take(100), SOURCE) }
+ "NOTE" -> arg.ifBlank { null }?.let {
+ val title = it.substringBefore('|').trim()
+ val body = it.substringAfter('|', "").trim()
+ LifeAction.CreateNote(title.take(60), body.ifBlank { title }, SOURCE)
+ }
+ "REMINDER" -> arg.ifBlank { null }?.let {
+ LifeAction.CreateReminder(it.take(80), System.currentTimeMillis() + 600_000L, SOURCE)
+ }
+ "TIMER" -> minutes?.let {
+ LifeAction.CreateReminder("Timer", System.currentTimeMillis() + it * 60_000L, SOURCE)
+ }
+ "FOCUS" -> LifeAction.StartFocusTimer(minutes ?: 25, SOURCE)
+ "BRICK_ON" -> arg.ifBlank { null }?.let { LifeAction.StartBrickMode(it, SOURCE) }
+ "BRICK_OFF" -> LifeAction.StopBrickMode(SOURCE)
+ "MACRO" -> arg.ifBlank { null }?.let { LifeAction.RunMacro(it, SOURCE) }
+ "PASTE" -> arg.ifBlank { null }?.let {
+ val title = it.substringBefore('|').trim()
+ val body = it.substringAfter('|', "").trim()
+ LifeAction.CreatePaste(title.take(60), body.ifBlank { title }, burner = false, password = "", source = SOURCE)
+ }
+ "DOWNLOAD" -> arg.ifBlank { null }?.let { LifeAction.StartDownload(it, SOURCE) }
+ "SCREEN_TIME_EXPORT" -> LifeAction.ExportScreenTime(arg.ifBlank { "json" }, weekOnly = true, source = SOURCE)
+ "SYNC_SCREEN_TIME" -> LifeAction.SyncScreenTime(SOURCE)
+ "WATER_PLANT" -> arg.ifBlank { null }?.let { LifeAction.WaterPlant(it, SOURCE) }
+ else -> null
+ }
+ }
+
+ /** Day-of-week list and optional window, both empty by default. */
+ private fun withinSchedule(rule: TriggerRuleEntity): Boolean {
+ val calendar = Calendar.getInstance()
+ if (rule.days.isNotBlank()) {
+ // Calendar is Sunday-based; the rule stores 1=Monday.
+ val today = ((calendar.get(Calendar.DAY_OF_WEEK) + 5) % 7) + 1
+ val allowed = rule.days.split(',').mapNotNull { it.trim().toIntOrNull() }
+ if (allowed.isNotEmpty() && today !in allowed) return false
+ }
+ if (rule.window.isNotBlank()) {
+ val parts = rule.window.split('-').mapNotNull { it.trim().toIntOrNull() }
+ if (parts.size == 2) {
+ val now = calendar.get(Calendar.HOUR_OF_DAY) * 60 + calendar.get(Calendar.MINUTE)
+ val (from, to) = parts
+ val inside = if (from <= to) now in from..to else now >= from || now <= to
+ if (!inside) return false
+ }
+ }
+ return true
+ }
+
+ private fun batteryPercent(): Int? = runCatching {
+ context.getSystemService(BatteryManager::class.java)
+ ?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
+ ?.takeIf { it in 0..100 }
+ }.getOrNull()
+
+ // ---- time rules --------------------------------------------------------
+
+ suspend fun rearmAll() {
+ val rules = triggerDao.allRules()
+ rules.forEach { rule ->
+ alarmManager.cancel(pendingIntent(rule.id))
+ if (!rule.enabled || rule.triggerType != "TIME") return@forEach
+ val minute = rule.triggerArg.filter { it.isDigit() }.toIntOrNull() ?: return@forEach
+ runCatching {
+ alarmManager.setAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP,
+ nextOccurrence(minute),
+ pendingIntent(rule.id),
+ )
+ }.onFailure { LifeLogger.w(TAG, "Could not arm rule ${rule.id}", it) }
+ }
+ }
+
+ suspend fun onAlarm(ruleId: Long) {
+ val rule = triggerDao.rule(ruleId) ?: return
+ if (rule.enabled && withinSchedule(rule)) run(rule, byHand = false)
+ rearmAll()
+ }
+
+ private fun pendingIntent(ruleId: Long): PendingIntent = PendingIntent.getBroadcast(
+ context,
+ (ruleId.toInt() * 31) + 7,
+ Intent(context, TriggerAlarmReceiver::class.java)
+ .setAction(TriggerAlarmReceiver.ACTION_FIRE)
+ .putExtra(TriggerAlarmReceiver.EXTRA_RULE_ID, ruleId),
+ 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 = "TriggerEngine"
+ /** Same rule cannot fire twice inside this window. */
+ const val COOLDOWN_MS = 60_000L
+ val SOURCE = SourceRef(LifeModule.AGENTIC, "trigger")
+ }
+}
+
+/** Fires one time rule and rearms it. */
+@AndroidEntryPoint
+class TriggerAlarmReceiver : BroadcastReceiver() {
+
+ @Inject
+ lateinit var engine: TriggerEngine
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val ruleId = intent.getLongExtra(EXTRA_RULE_ID, -1L)
+ if (ruleId == -1L) return
+ val pending = goAsync()
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ engine.onAlarm(ruleId)
+ } finally {
+ pending.finish()
+ }
+ }
+ }
+
+ companion object {
+ const val ACTION_FIRE = "com.lifeos.triggers.FIRE"
+ const val EXTRA_RULE_ID = "rule_id"
+ }
+}
diff --git a/feature/triggers/src/main/kotlin/com/lifeos/feature/triggers/data/TriggersJarvisBridge.kt b/feature/triggers/src/main/kotlin/com/lifeos/feature/triggers/data/TriggersJarvisBridge.kt
new file mode 100644
index 0000000..e48e8f0
--- /dev/null
+++ b/feature/triggers/src/main/kotlin/com/lifeos/feature/triggers/data/TriggersJarvisBridge.kt
@@ -0,0 +1,155 @@
+package com.lifeos.feature.triggers.data
+
+import com.lifeos.core.common.result.LifeError
+import com.lifeos.core.common.result.LifeResult
+import com.lifeos.core.database.triggers.TriggerDao
+import com.lifeos.core.database.triggers.TriggerRuleEntity
+import com.lifeos.core.service.LifeAction
+import com.lifeos.core.service.LifeActionHandler
+import com.lifeos.core.service.LifeDataProvider
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import javax.inject.Inject
+import javax.inject.Provider
+
+/** Rules as Jarvis reads them, including why something fired. */
+internal class TriggersProvider @Inject constructor(
+ private val triggerDao: TriggerDao,
+) : LifeDataProvider {
+
+ override val topic: String = "triggers"
+ override val description: String = "automation rules, plus the log of what fired"
+
+ override suspend fun read(query: String?): String {
+ val rules = triggerDao.allRules()
+ val fires = triggerDao.recentFires(10)
+ return buildString {
+ if (rules.isEmpty()) {
+ appendLine("No automation rules yet.")
+ } else {
+ appendLine("Rules (${rules.size}):")
+ rules.forEach { appendLine("- ${it.describe()}") }
+ }
+ if (fires.isNotEmpty()) {
+ appendLine("Recent fires:")
+ fires.forEach {
+ appendLine("- ${AT.format(Date(it.at))} ${it.ruleName}: ${it.outcome} ${it.detail}".trimEnd())
+ }
+ }
+ appendLine("Vocabulary - ${TriggerCatalog.promptVocabulary}")
+ }.trim()
+ }
+
+ private fun TriggerRuleEntity.describe(): String = buildString {
+ append(if (enabled) "" else "(off) ")
+ append(name)
+ append(": when ").append(triggerType)
+ if (triggerArg.isNotBlank()) append(" ").append(triggerArg)
+ append(" -> ").append(actionType)
+ if (actionArg.isNotBlank()) append(" ").append(actionArg)
+ if (days.isNotBlank()) append(" [days $days]")
+ if (window.isNotBlank()) append(" [window $window]")
+ if (fireCount > 0) append(" (fired $fireCount times)")
+ }
+
+ private companion object {
+ val AT = SimpleDateFormat("EEE HH:mm", Locale.getDefault())
+ }
+}
+
+/** Creating, toggling and running rules on Jarvis's word. */
+internal class TriggersActionHandler @Inject constructor(
+ private val triggerDao: TriggerDao,
+ /**
+ * Lazily, on purpose: the engine dispatches actions and this handler is one
+ * of them, so injecting it directly is a dependency cycle.
+ */
+ private val engineProvider: Provider,
+) : LifeActionHandler {
+
+ private val engine: TriggerEngine get() = engineProvider.get()
+
+ override fun canHandle(action: LifeAction): Boolean =
+ action is LifeAction.CreateTriggerRule ||
+ action is LifeAction.SetTriggerRuleEnabled ||
+ action is LifeAction.RunTriggerRule
+
+ override suspend fun execute(action: LifeAction): LifeResult = when (action) {
+ is LifeAction.CreateTriggerRule -> {
+ val triggerType = action.triggerType.uppercase()
+ val actionType = action.actionType.uppercase()
+ when {
+ triggerType !in TriggerCatalog.triggerByType ->
+ LifeResult.Failure(LifeError.Validation("Unknown trigger \"$triggerType\""))
+
+ actionType !in TriggerCatalog.actionByType ->
+ LifeResult.Failure(LifeError.Validation("Unknown action \"$actionType\""))
+
+ else -> {
+ val id = triggerDao.insertRule(
+ TriggerRuleEntity(
+ name = action.name.trim().ifBlank { "Rule" }.take(60),
+ triggerType = triggerType,
+ triggerArg = action.triggerArg.trim(),
+ days = action.days.trim(),
+ actionType = actionType,
+ actionArg = action.actionArg.trim(),
+ createdAt = System.currentTimeMillis(),
+ ),
+ )
+ engine.rearmAll()
+ LifeResult.Success(id)
+ }
+ }
+ }
+
+ is LifeAction.SetTriggerRuleEnabled -> {
+ val rule = findRule(action.ruleName)
+ if (rule == null) {
+ LifeResult.Failure(LifeError.Validation("No rule called \"${action.ruleName}\""))
+ } else {
+ triggerDao.updateRule(rule.copy(enabled = action.enabled))
+ engine.rearmAll()
+ LifeResult.Success(rule.id)
+ }
+ }
+
+ is LifeAction.RunTriggerRule -> {
+ val rule = findRule(action.ruleName)
+ if (rule == null) {
+ LifeResult.Failure(LifeError.Validation("No rule called \"${action.ruleName}\""))
+ } else {
+ engine.run(rule, byHand = true)
+ LifeResult.Success(rule.id)
+ }
+ }
+
+ else -> LifeResult.Failure(LifeError.Validation("Unsupported action"))
+ }
+
+ private suspend fun findRule(name: String): TriggerRuleEntity? {
+ val needle = name.trim().lowercase()
+ val rules = triggerDao.allRules()
+ return rules.firstOrNull { it.name.lowercase() == needle }
+ ?: rules.firstOrNull { needle in it.name.lowercase() }
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal abstract class TriggersJarvisModule {
+
+ @Binds
+ @IntoSet
+ abstract fun bindProvider(impl: TriggersProvider): LifeDataProvider
+
+ @Binds
+ @IntoSet
+ abstract fun bindHandler(impl: TriggersActionHandler): LifeActionHandler
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index eafe1eb..6f00fa3 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -51,6 +51,12 @@ include(":feature:downloader")
include(":feature:plants")
include(":feature:news")
include(":feature:vault")
+include(":core:places")
+include(":core:recall")
+include(":core:voice")
+include(":feature:signals")
+include(":feature:triggers")
+include(":feature:sync")
include(":feature:screentime")
include(":feature:brick")
include(":feature:pastebin")