diff --git a/README.md b/README.md index d346567..65e28e8 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,13 @@ and rule traces back to a section (and often a community demand source) there. | Brick tags are programmed on pairing (LifeOS MIME record + Android Application Record), so a tap flips a mode with the app closed and without a chooser; broader NDEF/TECH/TAG filters, tag id read from the record, and an NFC-off banner that opens NFC settings | Done | | Brick inverse mode: pick the window you want socials shut (e.g. 08:00-22:00) and the tag becomes the way in, not out - each tap buys a set stretch of access (15/30/60/120 min or a typed value), capped at one, two, three or unlimited unlocks per window, with the count resetting when the window next starts. Outside the window nothing is blocked. Covered by unit tests | Done | | One motion vocabulary app-wide (core:designsystem/Motion.kt): screens fade through each other with a barely-there scale, swapped content cross-fades (clock and Pastebin tabs, Brick list/editor, screen-time week/day stats, timer wheels/typed entry), banners fade in, and resizing layouts animate instead of snapping | Done | +| Motion slowed and simplified: fades run 420 ms in, 260 ms out, and every scale/corner-reveal is gone (screen transitions, the quick-capture button, the Vault reveal are pure cross-fades) | Done | +| Jarvis reaches every module: each feature registers a read provider and action handler through core:service, so he can pull screen-time numbers, plants, Brick state, headlines, the stargazing forecast, downloads, pastes and focus state, and act - create pastes (including one-time encrypted ones), queue downloads, water plants, start/stop Brick modes, run the focus timer, sync or export screen time, run macros. Detail is fetched on demand with `[[get: topic]]` and answered in a second pass, so the always-on prompt stays small | Done | +| Jarvis takes images: attach photos from the gallery, on-device Gemma reads them through a vision session (MediaPipe image modality) and NAS Ollama gets them as base64 - the attachment and the reply both stay in the thread | Done | +| Macros overhauled: build macros by hand with a "+" and a searchable action picker, edit anything (AI-written or not), reorder or delete steps, and test without saving. 50+ actions across apps, navigation, screen, timing, device and LifeOS itself (open app/link/settings, tap by text/description/view id, long-press, type, clear, scroll, four swipes, wait-for-text, media keys, volume, torch, vibrate, clipboard, share, toast, plus LifeOS tasks/notes/timers/focus/Brick). AI compilation now sees the installed app list, is told to use the fewest steps, and its output is normalised ("Spotify App" becomes Spotify) and opened in the editor for review | Done | +| 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 | | 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 0c1fa47..ea1b844 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,8 +10,8 @@ android { defaultConfig { applicationId = "com.lifeos" - versionCode = 21 - versionName = "0.1.0-alpha.21" + versionCode = 22 + versionName = "0.1.0-alpha.22" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 2a55c6b..d876152 100644 --- a/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt +++ b/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt @@ -28,7 +28,7 @@ import com.lifeos.app.ui.settings.SettingsRoute import com.lifeos.core.ui.navigation.LifeDestination import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn +import com.lifeos.core.designsystem.component.FadeVisible import com.lifeos.core.designsystem.component.LifeMotion import com.lifeos.core.ui.navigation.TopLevelDestination import com.lifeos.feature.adhd.FocusRoute @@ -98,11 +98,7 @@ fun LifeOsApp(captureRequests: Int = 0, navBarIds: List = emptyList()) { modifier = Modifier.fillMaxSize(), floatingActionButton = { // Quick capture lives on Home only; feature screens own their create FABs. - androidx.compose.animation.AnimatedVisibility( - visible = currentDestination?.hasRoute(LifeDestination.Home::class) == true, - enter = androidx.compose.animation.scaleIn() + androidx.compose.animation.fadeIn(), - exit = androidx.compose.animation.scaleOut() + androidx.compose.animation.fadeOut(), - ) { + FadeVisible(visible = currentDestination?.hasRoute(LifeDestination.Home::class) == true) { FloatingActionButton(onClick = { showQuickCapture = true }) { Icon(Icons.Filled.Bolt, contentDescription = "Quick capture") } @@ -141,13 +137,10 @@ fun LifeOsApp(captureRequests: Int = 0, navBarIds: List = emptyList()) { modifier = Modifier.padding(innerPadding), // One motion vocabulary (§7): screens fade through each other with a // barely-there scale, nothing slides or bounces. - enterTransition = { - fadeIn(LifeMotion.enterSpec()) + scaleIn(LifeMotion.enterSpec(), initialScale = 0.98f) - }, + // Pure cross-fades: any scale reads as a corner reveal on a phone. + enterTransition = { fadeIn(LifeMotion.enterSpec()) }, exitTransition = { fadeOut(LifeMotion.exitSpec()) }, - popEnterTransition = { - fadeIn(LifeMotion.enterSpec()) + scaleIn(LifeMotion.enterSpec(), initialScale = 1.01f) - }, + popEnterTransition = { fadeIn(LifeMotion.enterSpec()) }, popExitTransition = { fadeOut(LifeMotion.exitSpec()) }, ) { composable { 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 b32965a..3e3b9e3 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 @@ -1,61 +1,63 @@ package com.lifeos.app.ui.screen +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +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.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ContentPaste -import androidx.compose.material.icons.filled.NightsStay -import androidx.compose.material.icons.automirrored.filled.Note -import androidx.compose.material.icons.filled.Insights import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.automirrored.filled.Note +import androidx.compose.material.icons.automirrored.filled.ViewList 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.Home -import androidx.compose.material.icons.filled.Navigation -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Storage -import androidx.compose.material.icons.filled.DocumentScanner -import androidx.compose.material.icons.filled.LocalShipping -import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Bolt -import androidx.compose.material.icons.filled.Archive -import androidx.compose.material.icons.filled.SmartToy -import androidx.compose.material.icons.filled.Timeline -import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.DocumentScanner import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Insights import androidx.compose.material.icons.filled.LocalFlorist +import androidx.compose.material.icons.filled.LocalShipping +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Navigation import androidx.compose.material.icons.filled.Newspaper +import androidx.compose.material.icons.filled.NightsStay +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Shield +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Timelapse -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.automirrored.filled.ViewList -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.waitForUpOrCancellation -import kotlinx.coroutines.withTimeoutOrNull -import androidx.compose.material3.ListItem -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress -import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.material.icons.filled.Timeline import androidx.compose.material3.Card -import androidx.compose.material3.IconButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import com.lifeos.core.designsystem.component.smoothSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer @@ -65,11 +67,13 @@ import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round import androidx.compose.ui.zIndex -import androidx.compose.runtime.getValue import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.lifeos.core.designsystem.component.FadeVisible +import com.lifeos.core.designsystem.component.smoothSize import com.lifeos.core.ui.navigation.LifeDestination import com.lifeos.feature.planner.PlannerViewModel +import kotlinx.coroutines.withTimeoutOrNull private data class AppGridItem( val label: String, @@ -262,11 +266,7 @@ fun HomeScreen( } }, ) - androidx.compose.animation.AnimatedVisibility( - visible = vaultRevealed, - enter = androidx.compose.animation.scaleIn() + androidx.compose.animation.fadeIn(), - exit = androidx.compose.animation.scaleOut() + androidx.compose.animation.fadeOut(), - ) { + FadeVisible(visible = vaultRevealed) { IconButton(onClick = { onNavigate(LifeDestination.Vault) }) { Icon(Icons.Filled.Lock, contentDescription = "Vault", tint = MaterialTheme.colorScheme.primary) } @@ -349,6 +349,24 @@ private fun ReorderableTileGrid( IntRect(info.offset, info.size).contains(position.round()) } + // Auto-scroll while a tile is held near the top or bottom edge, so a long + // reorder does not need drag-release-drag again. + var edgeScroll by remember { mutableFloatStateOf(0f) } + LaunchedEffect(draggingKey) { + if (draggingKey == null) { + edgeScroll = 0f + return@LaunchedEffect + } + while (draggingKey != null) { + if (edgeScroll != 0f) { + gridState.scrollBy(edgeScroll) + // Keep the tile pinned under the finger while the list moves. + dragOffset += Offset(0f, edgeScroll) + } + withFrameNanos { } + } + } + LazyVerticalGrid( state = gridState, columns = if (listLayout) GridCells.Fixed(1) else GridCells.Adaptive(minSize = 160.dp), @@ -366,6 +384,16 @@ private fun ReorderableTileGrid( onDrag = { change, amount -> change.consume() dragOffset += amount + // Distance from the viewport edges decides the scroll speed. + val viewportHeight = gridState.layoutInfo.viewportSize.height.toFloat() + val pointerY = change.position.y + val zone = (viewportHeight * 0.18f).coerceAtLeast(72f) + edgeScroll = when { + pointerY < zone -> -((zone - pointerY) / zone) * MAX_EDGE_SCROLL + pointerY > viewportHeight - zone -> + ((pointerY - (viewportHeight - zone)) / zone) * MAX_EDGE_SCROLL + else -> 0f + } val key = draggingKey ?: return@detectDragGesturesAfterLongPress val dragged = gridState.layoutInfo.visibleItemsInfo .firstOrNull { it.key == key } ?: return@detectDragGesturesAfterLongPress @@ -390,11 +418,13 @@ private fun ReorderableTileGrid( onDragEnd = { draggingKey = null dragOffset = Offset.Zero + edgeScroll = 0f onOrderChanged(order.map { it.label }) }, onDragCancel = { draggingKey = null dragOffset = Offset.Zero + edgeScroll = 0f }, ) }, @@ -442,3 +472,6 @@ private fun ReorderableTileGrid( } } } + +/** Pixels per frame at the very edge of the viewport while reordering tiles. */ +private const val MAX_EDGE_SCROLL = 18f diff --git a/core/ai/build.gradle.kts b/core/ai/build.gradle.kts index 640cef8..512cf6a 100644 --- a/core/ai/build.gradle.kts +++ b/core/ai/build.gradle.kts @@ -11,5 +11,6 @@ dependencies { implementation(libs.okhttp) implementation(libs.kotlinx.serialization.json) implementation(libs.mediapipe.tasks.genai) + implementation(libs.mediapipe.tasks.vision) testImplementation(libs.turbine) } 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 c9edd25..03d1254 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 @@ -1,6 +1,11 @@ package com.lifeos.core.ai.engine.gemma import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import com.google.mediapipe.framework.image.BitmapImageBuilder +import com.google.mediapipe.tasks.genai.llminference.GraphOptions +import com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession import com.google.mediapipe.tasks.genai.llminference.LlmInference import com.lifeos.core.ai.engine.AiEngine import com.lifeos.core.ai.model.AiChunk @@ -44,6 +49,7 @@ class GemmaEngine @Inject constructor( private val mutex = Mutex() private var llm: LlmInference? = null private var loadedModelPath: String? = null + private var loadedWithVision = false // A dedicated single background thread so inference NEVER competes with the // shared IO pool (which the whole app uses) — that competition, plus the @@ -64,11 +70,16 @@ class GemmaEngine @Inject constructor( 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 { try { withTimeout(GENERATE_TIMEOUT_MS) { - val inference = loadIfNeeded(file.absolutePath) - inference.generateResponse(buildPrompt(request)) + val inference = loadIfNeeded(file.absolutePath, withVision = images.isNotEmpty()) + if (images.isEmpty()) { + inference.generateResponse(buildPrompt(request)) + } else { + generateWithImages(inference, buildPrompt(request), images) + } } } catch (t: Throwable) { // Poisoned session/OOM/timeout — drop the model so the next try is clean. @@ -90,16 +101,19 @@ class GemmaEngine @Inject constructor( LifeLogger.i(TAG, "Model released") } - private fun loadIfNeeded(path: String): LlmInference { + private fun loadIfNeeded(path: String, withVision: Boolean): LlmInference { val current = llm - if (current != null && loadedModelPath == path) return current + // Vision needs an image slot reserved at load time, so a text-only + // handle is reloaded the first time an image shows up (and vice versa). + if (current != null && loadedModelPath == path && loadedWithVision == withVision) return current current?.close() - LifeLogger.i(TAG, "Loading on-device model from $path (CPU)") + LifeLogger.i(TAG, "Loading on-device model from $path (CPU, vision=$withVision)") val options = LlmInference.LlmInferenceOptions.builder() .setModelPath(path) // Smaller cap = faster answers and far less memory pressure than 2048. - .setMaxTokens(MAX_TOKENS) + .setMaxTokens(if (withVision) MAX_TOKENS_VISION else MAX_TOKENS) + .apply { if (withVision) setMaxNumImages(MAX_IMAGES) } // CPU backend on purpose: the GPU delegate froze the S22 Ultra // compositor. Reliability over raw speed for on-device. .setPreferredBackend(LlmInference.Backend.CPU) @@ -107,9 +121,43 @@ class GemmaEngine @Inject constructor( return LlmInference.createFromOptions(context, options).also { llm = it loadedModelPath = path + loadedWithVision = withVision } } + /** + * Vision prompts go through a session: images are added as their own chunks + * alongside the text, which is what the multimodal Gemma builds expect. + * Decoding the bitmaps is bounded so a 12 MP photo cannot blow up memory. + */ + private fun generateWithImages(inference: LlmInference, prompt: String, imagePaths: List): String { + val sessionOptions = LlmInferenceSession.LlmInferenceSessionOptions.builder() + .setGraphOptions(GraphOptions.builder().setEnableVisionModality(true).build()) + .build() + return LlmInferenceSession.createFromOptions(inference, sessionOptions).use { session -> + imagePaths.forEach { path -> + val bitmap = decodeBounded(path) + if (bitmap != null) { + session.addImage(BitmapImageBuilder(bitmap).build()) + } + } + session.addQueryChunk(prompt) + session.generateResponse() + } + } + + /** Decodes at most [MAX_IMAGE_EDGE] px on the long edge. */ + private fun decodeBounded(path: String): Bitmap? = runCatching { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + val longest = maxOf(bounds.outWidth, bounds.outHeight).coerceAtLeast(1) + val options = BitmapFactory.Options().apply { + inSampleSize = 1 + while (longest / inSampleSize > MAX_IMAGE_EDGE) inSampleSize *= 2 + } + BitmapFactory.decodeFile(path, options) + }.getOrNull() + private suspend fun modelFile(): File? { val configured = aiConfigRepository.config.first().onDeviceModelPath if (configured.isNotBlank()) return File(configured) @@ -175,5 +223,9 @@ class GemmaEngine @Inject constructor( // window is always left for the reply, whatever the caller sends. const val MAX_PROMPT_CHARS = 2600 const val GENERATE_TIMEOUT_MS = 90_000L + // Vision prompts need headroom for the image tokens on top of the text. + const val MAX_TOKENS_VISION = 2048 + const val MAX_IMAGES = 2 + const val MAX_IMAGE_EDGE = 768 } } diff --git a/core/ai/src/main/kotlin/com/lifeos/core/ai/engine/ollama/OllamaProtocol.kt b/core/ai/src/main/kotlin/com/lifeos/core/ai/engine/ollama/OllamaProtocol.kt index 03be9d9..6f23dc0 100644 --- a/core/ai/src/main/kotlin/com/lifeos/core/ai/engine/ollama/OllamaProtocol.kt +++ b/core/ai/src/main/kotlin/com/lifeos/core/ai/engine/ollama/OllamaProtocol.kt @@ -24,6 +24,8 @@ internal data class OllamaChatRequest( internal data class OllamaMessage( val role: String, val content: String, + /** Base64 images, which is how Ollama takes vision input. */ + val images: List? = null, ) @Serializable @@ -65,6 +67,13 @@ internal object OllamaProtocol { AiRole.ASSISTANT -> "assistant" }, content = content, + images = imagePaths + .mapNotNull { path -> + runCatching { + java.util.Base64.getEncoder().encodeToString(java.io.File(path).readBytes()) + }.getOrNull() + } + .takeIf { it.isNotEmpty() }, ) } diff --git a/core/ai/src/main/kotlin/com/lifeos/core/ai/macro/MacroCatalog.kt b/core/ai/src/main/kotlin/com/lifeos/core/ai/macro/MacroCatalog.kt new file mode 100644 index 0000000..e687c29 --- /dev/null +++ b/core/ai/src/main/kotlin/com/lifeos/core/ai/macro/MacroCatalog.kt @@ -0,0 +1,91 @@ +package com.lifeos.core.ai.macro + +/** What one macro action needs from the user, so editors can be generated. */ +enum class MacroArg { NONE, TARGET, TEXT, DELAY } + +/** One action the executor understands, with everything a UI needs to offer it. */ +data class MacroActionSpec( + val action: String, + val label: String, + val group: String, + val arg: MacroArg, + val hint: String, +) + +/** + * The macro vocabulary (§Module 12). + * + * Everything here is implemented by the accessibility executor, so the compiler, + * the manual editor and the runner all agree on exactly one list. Actions that + * Android only allows with an extra grant say so in their hint rather than + * failing silently at run time. + */ +object MacroCatalog { + + val actions: List = listOf( + // ---- apps and navigation ------------------------------------------ + MacroActionSpec("LAUNCH", "Open app", "Apps", MacroArg.TARGET, "App name or package, e.g. Spotify"), + MacroActionSpec("LAUNCH_URL", "Open link", "Apps", MacroArg.TARGET, "https://… or any deep link"), + MacroActionSpec("OPEN_SETTINGS", "Open settings page", "Apps", MacroArg.TARGET, "wifi, bluetooth, nfc, apps, battery, display, sound, location, dnd"), + MacroActionSpec("DIAL", "Open dialer", "Apps", MacroArg.TARGET, "Phone number to pre-fill (never dials by itself)"), + MacroActionSpec("BACK", "Back", "Navigation", MacroArg.NONE, ""), + MacroActionSpec("HOME", "Home", "Navigation", MacroArg.NONE, ""), + MacroActionSpec("RECENTS", "Recent apps", "Navigation", MacroArg.NONE, ""), + MacroActionSpec("NOTIFICATIONS", "Open notifications", "Navigation", MacroArg.NONE, ""), + MacroActionSpec("QUICK_SETTINGS", "Open quick settings", "Navigation", MacroArg.NONE, ""), + MacroActionSpec("POWER_DIALOG", "Power menu", "Navigation", MacroArg.NONE, ""), + MacroActionSpec("LOCK_SCREEN", "Lock the screen", "Navigation", MacroArg.NONE, ""), + MacroActionSpec("SCREENSHOT", "Take a screenshot", "Navigation", MacroArg.NONE, ""), + MacroActionSpec("SPLIT_SCREEN", "Split screen", "Navigation", MacroArg.NONE, ""), + + // ---- touching the screen ------------------------------------------ + MacroActionSpec("CLICK", "Tap text", "Screen", MacroArg.TARGET, "Visible text to tap"), + MacroActionSpec("CLICK_DESC", "Tap by description", "Screen", MacroArg.TARGET, "Content description of the button"), + MacroActionSpec("CLICK_ID", "Tap by view id", "Screen", MacroArg.TARGET, "Resource id, e.g. com.app:id/search"), + MacroActionSpec("LONG_CLICK", "Long-press text", "Screen", MacroArg.TARGET, "Visible text to hold"), + MacroActionSpec("INPUT", "Type text", "Screen", MacroArg.TEXT, "Text typed into the focused field"), + MacroActionSpec("CLEAR_INPUT", "Clear the field", "Screen", MacroArg.NONE, ""), + MacroActionSpec("SCROLL_FORWARD", "Scroll down", "Screen", MacroArg.NONE, ""), + MacroActionSpec("SCROLL_BACKWARD", "Scroll up", "Screen", MacroArg.NONE, ""), + MacroActionSpec("SWIPE_UP", "Swipe up", "Screen", MacroArg.NONE, ""), + MacroActionSpec("SWIPE_DOWN", "Swipe down", "Screen", MacroArg.NONE, ""), + MacroActionSpec("SWIPE_LEFT", "Swipe left", "Screen", MacroArg.NONE, ""), + MacroActionSpec("SWIPE_RIGHT", "Swipe right", "Screen", MacroArg.NONE, ""), + + // ---- timing -------------------------------------------------------- + MacroActionSpec("WAIT", "Wait", "Timing", MacroArg.DELAY, "Pause in milliseconds (max 10000)"), + MacroActionSpec("WAIT_FOR", "Wait for text", "Timing", MacroArg.TARGET, "Waits up to 10s for this text to appear"), + + // ---- device -------------------------------------------------------- + MacroActionSpec("MEDIA_PLAY_PAUSE", "Play or pause media", "Device", MacroArg.NONE, ""), + MacroActionSpec("MEDIA_NEXT", "Next track", "Device", MacroArg.NONE, ""), + MacroActionSpec("MEDIA_PREV", "Previous track", "Device", MacroArg.NONE, ""), + MacroActionSpec("VOLUME_UP", "Volume up", "Device", MacroArg.NONE, ""), + MacroActionSpec("VOLUME_DOWN", "Volume down", "Device", MacroArg.NONE, ""), + MacroActionSpec("VOLUME_MUTE", "Mute", "Device", MacroArg.NONE, ""), + MacroActionSpec("TORCH_ON", "Torch on", "Device", MacroArg.NONE, ""), + MacroActionSpec("TORCH_OFF", "Torch off", "Device", MacroArg.NONE, ""), + MacroActionSpec("VIBRATE", "Vibrate", "Device", MacroArg.NONE, ""), + MacroActionSpec("CLIPBOARD", "Copy to clipboard", "Device", MacroArg.TEXT, "Text to put on the clipboard"), + MacroActionSpec("SHARE", "Open the share sheet", "Device", MacroArg.TEXT, "Text to share"), + MacroActionSpec("TOAST", "Show a toast", "Device", MacroArg.TEXT, "Message to flash on screen"), + + // ---- LifeOS itself ------------------------------------------------- + MacroActionSpec("LIFEOS_TASK", "Add a LifeOS task", "LifeOS", MacroArg.TEXT, "Task title"), + MacroActionSpec("LIFEOS_NOTE", "Add a LifeOS note", "LifeOS", MacroArg.TEXT, "Note title, then | body"), + MacroActionSpec("LIFEOS_TIMER", "Set a LifeOS timer", "LifeOS", MacroArg.DELAY, "Milliseconds from now"), + MacroActionSpec("LIFEOS_FOCUS", "Start the focus timer", "LifeOS", MacroArg.DELAY, "Milliseconds of focus"), + MacroActionSpec("LIFEOS_BRICK_ON", "Start a Brick mode", "LifeOS", MacroArg.TARGET, "Mode name"), + MacroActionSpec("LIFEOS_BRICK_OFF", "End the Brick mode", "LifeOS", MacroArg.NONE, ""), + ) + + val byAction: Map = actions.associateBy { it.action } + + val groups: List = actions.map { it.group }.distinct() + + /** Compact list for the compiler prompt: only names, to keep tokens down. */ + val promptVocabulary: String = actions.joinToString(", ") { it.action } +} + +@Deprecated("Use MacroCatalog.byAction.keys", ReplaceWith("MacroCatalog.byAction.keys")) +val SUPPORTED_MACRO_ACTIONS: Set = MacroCatalog.byAction.keys diff --git a/core/ai/src/main/kotlin/com/lifeos/core/ai/macro/MacroCompiler.kt b/core/ai/src/main/kotlin/com/lifeos/core/ai/macro/MacroCompiler.kt index 6ef310b..f72fa9a 100644 --- a/core/ai/src/main/kotlin/com/lifeos/core/ai/macro/MacroCompiler.kt +++ b/core/ai/src/main/kotlin/com/lifeos/core/ai/macro/MacroCompiler.kt @@ -19,18 +19,16 @@ import javax.inject.Singleton */ @Serializable data class MacroStep( - /** LAUNCH | CLICK | INPUT | BACK | HOME | WAIT */ + /** One of [MacroCatalog.actions]; anything else is rejected at compile time. */ val action: String, /** LAUNCH: app name or package. CLICK: visible text to tap. */ val target: String? = null, - /** INPUT: text typed into the focused field. */ + /** INPUT and friends: the literal text. */ val text: String? = null, - /** WAIT: delay in milliseconds (capped at 10s). */ + /** WAIT and the LifeOS timers: milliseconds (capped at 10s for WAIT). */ val delayMs: Long? = null, ) -val SUPPORTED_MACRO_ACTIONS = setOf("LAUNCH", "CLICK", "INPUT", "BACK", "HOME", "WAIT") - /** * NL → validated macro IR (§5, [src 41]). The model output is parsed and * validated; unsupported steps fail the whole compile so the preview the @@ -43,14 +41,27 @@ class MacroCompiler @Inject constructor( private val json = Json { ignoreUnknownKeys = true } - suspend fun compile(nlPrompt: String): LifeResult> { + /** + * @param installedApps labels of apps actually on the phone. Given to the + * model so it stops inventing targets like "Spotify App", and used again + * after parsing to normalise whatever it produced. + */ + suspend fun compile(nlPrompt: String, installedApps: List = emptyList()): LifeResult> { + val appHint = if (installedApps.isEmpty()) { + "" + } else { + " Installed apps you may use verbatim as LAUNCH targets: " + + installedApps.take(60).joinToString(", ") + "." + } val request = AiRequest( - system = "Compile a phone automation described in natural language into steps. " + - "Reply with ONLY a minified JSON array of steps, no prose. Each step: " + - """{"action":"LAUNCH"|"CLICK"|"INPUT"|"BACK"|"HOME"|"WAIT","target":string?,"text":string?,"delayMs":number?}. """ + - "LAUNCH opens an app by name (target). CLICK taps visible text (target). " + - "INPUT types text into the focused field (text). WAIT pauses (delayMs). " + - "Use WAIT 1500 after every LAUNCH. Maximum 12 steps.", + system = "Compile a phone automation into steps. Reply with ONLY a minified JSON array, no prose. " + + """Step shape: {"action":"…","target":string?,"text":string?,"delayMs":number?}. """ + + "Allowed actions: " + MacroCatalog.promptVocabulary + ". " + + "Rules: use the FEWEST steps that do the job - \"open Spotify\" is exactly " + + """[{"action":"LAUNCH","target":"Spotify"},{"action":"WAIT","delayMs":1500}] and nothing more. """ + + "LAUNCH target is the app's exact name, never with the word App appended. " + + "Only add CLICK/INPUT steps the user actually asked for; never guess button names. " + + "Maximum 12 steps." + appHint, messages = listOf(AiMessage(AiRole.USER, nlPrompt)), localOnly = true, ) @@ -58,10 +69,10 @@ class MacroCompiler @Inject constructor( is LifeResult.Success -> result.value.text is LifeResult.Failure -> return result } - return parse(raw) + return parse(raw, installedApps) } - internal fun parse(raw: String): LifeResult> { + fun parse(raw: String, installedApps: List = emptyList()): LifeResult> { val start = raw.indexOf('[') val end = raw.lastIndexOf(']') if (start == -1 || end <= start) { @@ -75,14 +86,45 @@ class MacroCompiler @Inject constructor( if (steps.isEmpty()) return LifeResult.Failure(LifeError.Validation("The macro compiled to zero steps")) if (steps.size > 12) return LifeResult.Failure(LifeError.Validation("Macros are capped at 12 steps")) steps.forEach { step -> - if (step.action !in SUPPORTED_MACRO_ACTIONS) { + if (step.action.uppercase() !in MacroCatalog.byAction) { return LifeResult.Failure( LifeError.Validation("Unsupported step '${step.action}' — this macro cannot run"), ) } } - return LifeResult.Success( - steps.map { if ((it.delayMs ?: 0) > 10_000) it.copy(delayMs = 10_000) else it }, - ) + val cleaned = steps + .map { step -> + var next = step.copy(action = step.action.uppercase()) + if (next.action == "WAIT" && (next.delayMs ?: 0) > 10_000) next = next.copy(delayMs = 10_000) + if (next.action == "LAUNCH") next = next.copy(target = normalizeAppTarget(next.target, installedApps)) + next + } + // Small models love to repeat a step; collapse exact duplicates that + // sit next to each other. + .filterIndexed { index, step -> index == 0 || step != steps.getOrNull(index - 1) } + val badLaunch = cleaned.firstOrNull { it.action == "LAUNCH" && it.target.isNullOrBlank() } + if (badLaunch != null) { + return LifeResult.Failure(LifeError.Validation("A LAUNCH step has no app - name the app and retry")) + } + return LifeResult.Success(cleaned) + } + + /** + * Maps whatever the model wrote onto a real app label: strips the "app" + * suffix models like to add, then matches case-insensitively against what is + * installed. Leaves the value alone when nothing matches, so a package name + * still works. + */ + private fun normalizeAppTarget(target: String?, installedApps: List): String? { + val raw = target?.trim()?.takeIf { it.isNotEmpty() } ?: return target + val stripped = raw + .replace(Regex("(?i)\\s+(app|application)$"), "") + .trim() + .trim('"', '\'') + if (installedApps.isEmpty()) return stripped + return installedApps.firstOrNull { it.equals(stripped, ignoreCase = true) } + ?: installedApps.firstOrNull { it.contains(stripped, ignoreCase = true) } + ?: installedApps.firstOrNull { stripped.contains(it, ignoreCase = true) } + ?: stripped } } 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 660a20c..2feb7d5 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 @@ -11,6 +11,11 @@ enum class AiRole { SYSTEM, USER, ASSISTANT } data class AiMessage( val role: AiRole, val content: String, + /** + * Local file paths of images that belong to this turn. Vision-capable + * engines feed them to the model; text-only ones ignore them. + */ + val imagePaths: List = emptyList(), ) /** diff --git a/core/database/schemas/com.lifeos.core.database.LifeDatabase/16.json b/core/database/schemas/com.lifeos.core.database.LifeDatabase/16.json new file mode 100644 index 0000000..fe4c6ca --- /dev/null +++ b/core/database/schemas/com.lifeos.core.database.LifeDatabase/16.json @@ -0,0 +1,2071 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "26dca51fd18a210892c93eb8d74861ef", + "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" + ] + } + } + ], + "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, '26dca51fd18a210892c93eb8d74861ef')" + ] + } +} \ 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 8104c12..4f8d222 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 @@ -105,7 +105,7 @@ import com.lifeos.core.database.screentime.ScreenTimeDayEntity BrickSessionEntity::class, BrickUsageEntity::class, ], - version = 15, + version = 16, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -122,6 +122,7 @@ import com.lifeos.core.database.screentime.ScreenTimeDayEntity AutoMigration(from = 12, to = 13), AutoMigration(from = 13, to = 14), AutoMigration(from = 14, to = 15), + AutoMigration(from = 15, to = 16), ], ) abstract class LifeDatabase : RoomDatabase() { diff --git a/core/database/src/main/kotlin/com/lifeos/core/database/chat/ChatEntities.kt b/core/database/src/main/kotlin/com/lifeos/core/database/chat/ChatEntities.kt index ca34a71..5f59ac8 100644 --- a/core/database/src/main/kotlin/com/lifeos/core/database/chat/ChatEntities.kt +++ b/core/database/src/main/kotlin/com/lifeos/core/database/chat/ChatEntities.kt @@ -1,5 +1,6 @@ package com.lifeos.core.database.chat +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index @@ -37,4 +38,6 @@ data class AiMessageEntity( val content: String, val engine: String?, val createdAt: Long, + /** Newline-separated local image paths attached to this turn. */ + @ColumnInfo(defaultValue = "") val imagePaths: String = "", ) 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 877be84..a975af1 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 @@ -118,6 +118,13 @@ internal class DataStoreSettingsRepository @Inject constructor( dataStore.edit { prefs -> prefs[KEY_PASTEBIN_USER_KEY] = key.trim() } } + override val privateBinInstance: Flow = + dataStore.data.map { prefs -> prefs[KEY_PRIVATEBIN_INSTANCE] ?: "" } + + override suspend fun setPrivateBinInstance(url: String) { + dataStore.edit { prefs -> prefs[KEY_PRIVATEBIN_INSTANCE] = url.trim() } + } + override val clearSkyPlaces: Flow = dataStore.data.map { prefs -> prefs[KEY_CLEAR_SKY_PLACES] ?: "" } @@ -154,6 +161,7 @@ internal class DataStoreSettingsRepository @Inject constructor( val KEY_SCREEN_TIME_REBUILT = booleanPreferencesKey("screen_time_rebuilt_v2") 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_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 14f457f..255db7c 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 @@ -80,6 +80,11 @@ interface SettingsRepository { suspend fun setPastebinUserKey(key: String) + /** PrivateBin instance used for burner pastes; empty = the public default. */ + val privateBinInstance: Flow + + suspend fun setPrivateBinInstance(url: String) + /** Saved Clear Sky observing spots, one "name~lat~lon" per line. */ val clearSkyPlaces: Flow diff --git a/core/designsystem/src/main/kotlin/com/lifeos/core/designsystem/component/Motion.kt b/core/designsystem/src/main/kotlin/com/lifeos/core/designsystem/component/Motion.kt index 15a37f1..fae22f3 100644 --- a/core/designsystem/src/main/kotlin/com/lifeos/core/designsystem/component/Motion.kt +++ b/core/designsystem/src/main/kotlin/com/lifeos/core/designsystem/component/Motion.kt @@ -8,8 +8,6 @@ import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -21,17 +19,17 @@ import androidx.compose.ui.Modifier * Durations are deliberately short - the app should feel calm, not slow. */ object LifeMotion { - /** Leaving the screen: fast, so nothing feels sticky. */ - const val EXIT_MS = 130 + /** Leaving the screen. */ + const val EXIT_MS = 260 - /** Arriving: still quick, but soft enough to read as a fade. */ - const val ENTER_MS = 220 + /** Arriving: long enough to read as a real fade, short enough to stay snappy. */ + const val ENTER_MS = 420 /** Cross-fading one piece of content for another. */ - const val SWAP_MS = 240 + const val SWAP_MS = 420 /** Layout growing or shrinking. */ - const val RESIZE_MS = 260 + const val RESIZE_MS = 380 fun enterSpec() = tween(durationMillis = ENTER_MS, easing = LinearOutSlowInEasing) fun exitSpec() = tween(durationMillis = EXIT_MS, easing = FastOutSlowInEasing) @@ -66,8 +64,8 @@ fun FadeVisible( AnimatedVisibility( visible = visible, modifier = modifier, - enter = fadeIn(LifeMotion.enterSpec()) + scaleIn(LifeMotion.enterSpec(), initialScale = 0.97f), - exit = fadeOut(LifeMotion.exitSpec()) + scaleOut(LifeMotion.exitSpec(), targetScale = 0.97f), + enter = fadeIn(LifeMotion.enterSpec()), + exit = fadeOut(LifeMotion.exitSpec()), label = label, content = { content() }, ) diff --git a/core/service/src/main/kotlin/com/lifeos/core/service/ActionEcho.kt b/core/service/src/main/kotlin/com/lifeos/core/service/ActionEcho.kt new file mode 100644 index 0000000..07426ed --- /dev/null +++ b/core/service/src/main/kotlin/com/lifeos/core/service/ActionEcho.kt @@ -0,0 +1,32 @@ +package com.lifeos.core.service + +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A one-slot mailbox for action results that are worth quoting back (§1.5). + * + * [LifeActionHandler] only returns a row id, but some actions produce something + * the user actually needs - a paste URL, a saved file name. Handlers drop it + * here and the caller (Jarvis, the UI) reads it right after dispatching, which + * avoids widening the action contract for every one-off payload. + */ +@Singleton +class ActionEcho @Inject constructor() { + + @Volatile + var lastUrl: String? = null + private set + + @Volatile + var lastFileName: String? = null + private set + + fun url(value: String?) { + lastUrl = value + } + + fun fileName(value: String?) { + lastFileName = value + } +} 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 64e56a8..315032f 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 @@ -62,4 +62,76 @@ sealed interface LifeAction { val endsAt: Long, override val source: SourceRef, ) : LifeAction + + // ---- Jarvis-facing actions (§Module 9) --------------------------------- + // Everything Jarvis can DO in a module that is not its own lives here, so + // the chat module never has to depend on a feature module. + + /** Creates a paste; [burner] routes it to the encrypted burn-after-read backend. */ + data class CreatePaste( + val title: String, + val content: String, + val burner: Boolean, + val password: String, + override val source: SourceRef, + ) : LifeAction + + /** Finds media on a page and queues the best stream into Downloads. */ + data class StartDownload( + val url: String, + override val source: SourceRef, + ) : LifeAction + + /** Turns a Brick mode on; [modeName] is matched loosely against saved modes. */ + data class StartBrickMode( + val modeName: String, + override val source: SourceRef, + ) : LifeAction + + /** Ends the running Brick mode, if its rules allow it. */ + data class StopBrickMode( + override val source: SourceRef, + ) : LifeAction + + /** Marks a plant as watered right now. */ + data class WaterPlant( + val plantName: String, + override val source: SourceRef, + ) : LifeAction + + /** Adds a plant to the shelf. */ + data class AddPlant( + val plantName: String, + val species: String, + val waterEveryDays: Int, + override val source: SourceRef, + ) : LifeAction + + /** Starts (or restarts) the Focus timer. */ + data class StartFocusTimer( + val minutes: Int, + override val source: SourceRef, + ) : LifeAction + + data class StopFocusTimer( + override val source: SourceRef, + ) : LifeAction + + /** Writes a screen-time export into Downloads; format is JSON/CSV_DAYS/CSV_APPS. */ + data class ExportScreenTime( + val format: String, + val weekOnly: Boolean, + override val source: SourceRef, + ) : LifeAction + + /** Pulls fresh screen-time data from Android's usage stats. */ + data class SyncScreenTime( + override val source: SourceRef, + ) : LifeAction + + /** Runs a saved accessibility macro by name. */ + data class RunMacro( + val macroName: String, + override val source: SourceRef, + ) : LifeAction } diff --git a/core/service/src/main/kotlin/com/lifeos/core/service/LifeDataProvider.kt b/core/service/src/main/kotlin/com/lifeos/core/service/LifeDataProvider.kt new file mode 100644 index 0000000..7546b07 --- /dev/null +++ b/core/service/src/main/kotlin/com/lifeos/core/service/LifeDataProvider.kt @@ -0,0 +1,28 @@ +package com.lifeos.core.service + +/** + * A module's own read-side, exposed to Jarvis on demand (§Module 9). + * + * The always-on prompt snapshot stays small on purpose: on-device inference pays + * for every character, in latency and in output budget. Detail lives behind this + * interface instead, and is only fetched when the model asks for it with a + * `[[get: topic]]` line - so a chat about poetry costs nothing extra, while a + * question about screen time gets the real numbers. + * + * Features register providers with `@IntoSet`, which keeps the chat module free + * of feature-to-feature dependencies. + */ +interface LifeDataProvider { + + /** Lower-case topic the model names, e.g. "screen_time", "plants", "sky". */ + val topic: String + + /** One-line description of what this topic contains, listed in the tool spec. */ + val description: String + + /** + * Compact, model-readable detail. [query] is whatever followed the topic + * (a place, a name, a number of days) or null. + */ + suspend fun read(query: String?): String +} diff --git a/core/ui/src/main/kotlin/com/lifeos/core/ui/component/AiInputBar.kt b/core/ui/src/main/kotlin/com/lifeos/core/ui/component/AiInputBar.kt index 1053530..4ba8acc 100644 --- a/core/ui/src/main/kotlin/com/lifeos/core/ui/component/AiInputBar.kt +++ b/core/ui/src/main/kotlin/com/lifeos/core/ui/component/AiInputBar.kt @@ -8,10 +8,12 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Image import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -38,6 +40,9 @@ fun AiInputBar( modifier: Modifier = Modifier, placeholder: String = "Ask or tell Jarvis anything", busy: Boolean = false, + /** Shown when set: adds a gallery button and lets an image be sent alone. */ + onAttachImage: (() -> Unit)? = null, + hasAttachments: Boolean = false, ) { Surface( modifier = modifier.fillMaxWidth(), @@ -46,6 +51,19 @@ fun AiInputBar( tonalElevation = 2.dp, ) { Row(verticalAlignment = Alignment.CenterVertically) { + if (onAttachImage != null) { + IconButton(onClick = onAttachImage, modifier = Modifier.padding(start = 4.dp)) { + Icon( + Icons.Filled.Image, + contentDescription = "Attach image", + tint = if (hasAttachments) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } TextField( value = value, onValueChange = onValueChange, @@ -69,7 +87,7 @@ fun AiInputBar( } else { FilledIconButton( onClick = onSend, - enabled = value.isNotBlank(), + enabled = value.isNotBlank() || hasAttachments, modifier = Modifier.padding(end = 6.dp), ) { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send") diff --git a/feature/adhd/build.gradle.kts b/feature/adhd/build.gradle.kts index e919233..e5c2aaf 100644 --- a/feature/adhd/build.gradle.kts +++ b/feature/adhd/build.gradle.kts @@ -6,6 +6,7 @@ plugins { dependencies { implementation(projects.core.common) + implementation(projects.core.service) implementation(projects.core.designsystem) implementation(projects.core.database) implementation(projects.core.ui) diff --git a/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/FocusScreen.kt b/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/FocusScreen.kt index 73d00ac..d01570e 100644 --- a/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/FocusScreen.kt +++ b/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/FocusScreen.kt @@ -6,6 +6,7 @@ import android.os.VibrationEffect import android.os.VibratorManager import android.provider.Settings import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,9 +16,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -42,20 +44,29 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.lifeos.core.designsystem.component.EmptyState +import com.lifeos.core.designsystem.component.FadeThrough +import com.lifeos.feature.adhd.data.FocusTimerController import com.lifeos.feature.adhd.overlay.OverwhelmOverlayService import com.lifeos.feature.adhd.overlay.TimerOverlayService -import kotlinx.coroutines.delay import java.text.DateFormat import java.util.Date +import kotlinx.coroutines.delay /** ADHD tools (§Module 5): visual focus timer, streaks, overwhelm overlay. */ @OptIn(ExperimentalMaterial3Api::class) @@ -76,53 +87,35 @@ fun FocusRoute(viewModel: FocusViewModel = hiltViewModel()) { ) } } - when (uiState.tab) { - 0 -> TimerTab(onFinished = { minutes, completed -> - viewModel.onEvent(FocusUiEvent.SessionFinished(minutes, completed)) - }) - 1 -> StreaksTab(uiState) - else -> OverwhelmTab() + FadeThrough(targetState = uiState.tab, label = "focus-tab") { tab -> + when (tab) { + 0 -> TimerTab(viewModel.timerController) + 1 -> StreaksTab(uiState) + else -> OverwhelmTab() + } } } } } +/** + * Focus timer (§Module 5). All state lives in [FocusTimerController], so the + * countdown keeps running across tabs, Home and app switches. Tap the time in + * the middle of the ring to edit it right there - no dialog. + */ @Composable -private fun TimerTab(onFinished: (minutes: Int, completed: Boolean) -> Unit) { +private fun TimerTab(controller: FocusTimerController) { val context = LocalContext.current - // Total duration in seconds and the running remaining seconds are kept apart - // so a custom time survives a reset. Default 25 min. - var totalSeconds by remember { mutableIntStateOf(25 * 60) } - var remainingSeconds by remember { mutableIntStateOf(25 * 60) } - var running by remember { mutableStateOf(false) } - var showCustom by remember { mutableStateOf(false) } - var overlayOn by remember { mutableStateOf(false) } + val timer by controller.state.collectAsStateWithLifecycle() + var editing by remember { mutableStateOf(false) } + var draft by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current - // Absolute deadline drives both the on-screen ring AND the OS overlay so they - // never drift apart while the overlay is up. - fun startOverlayIfOn() { - if (overlayOn) { - TimerOverlayService.show( - context, - android.os.SystemClock.elapsedRealtime() + remainingSeconds * 1000L, - totalSeconds * 1000L, - ) - } - } - - LaunchedEffect(running) { - while (running && remainingSeconds > 0) { - delay(1_000) - remainingSeconds -= 1 - } - if (running && remainingSeconds == 0) { - running = false - onFinished(totalSeconds / 60, true) - TimerOverlayService.hide(context) - overlayOn = false - val vibrator = (context.getSystemService(VibratorManager::class.java)).defaultVibrator - vibrator.vibrate(VibrationEffect.createWaveform(longArrayOf(0, 200, 100, 200, 100, 400), -1)) - } + fun commit() { + parseClock(draft)?.let { controller.setTotal(it) } + editing = false + keyboard?.hide() } Column( @@ -133,56 +126,70 @@ private fun TimerTab(onFinished: (minutes: Int, completed: Boolean) -> Unit) { verticalArrangement = Arrangement.spacedBy(24.dp), ) { Box(contentAlignment = Alignment.Center) { - val progress = if (totalSeconds == 0) 0f else remainingSeconds / (totalSeconds.toFloat()) + val progress = if (timer.totalSeconds == 0) { + 0f + } else { + timer.remainingSeconds / timer.totalSeconds.toFloat() + } val track = MaterialTheme.colorScheme.surfaceVariant val bar = MaterialTheme.colorScheme.primary - Canvas( - modifier = Modifier - .size(240.dp) - // Tap inside the ring to set a custom time. - .pointerInput(Unit) { detectTapGestures { showCustom = true } }, - ) { + Canvas(modifier = Modifier.size(240.dp)) { drawArc(track, -90f, 360f, false, style = Stroke(width = 28f, cap = StrokeCap.Round)) drawArc(bar, -90f, 360f * progress, false, style = Stroke(width = 28f, cap = StrokeCap.Round)) } - Text( - formatClock(remainingSeconds), - style = MaterialTheme.typography.displayMedium, - ) + if (editing) { + // Editing happens in place: the clock in the middle becomes the field. + BasicTextField( + value = draft, + onValueChange = { input -> draft = input.filter { it.isDigit() || it == ':' }.take(8) }, + singleLine = true, + textStyle = MaterialTheme.typography.displayMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { commit() }), + modifier = Modifier + .width(180.dp) + .focusRequester(focusRequester) + .onFocusChanged { if (!it.isFocused && editing) commit() }, + ) + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } else { + Text( + formatClock(timer.remainingSeconds), + style = MaterialTheme.typography.displayMedium, + modifier = Modifier.pointerInput(timer.remainingSeconds) { + detectTapGestures { + draft = formatClock(timer.remainingSeconds) + editing = true + } + }, + ) + } } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { listOf(5, 15, 25, 45).forEach { preset -> FilterChip( - selected = !running && totalSeconds == preset * 60, - onClick = { - totalSeconds = preset * 60 - remainingSeconds = preset * 60 - running = false - }, + selected = !timer.running && timer.totalSeconds == preset * 60, + onClick = { controller.setTotal(preset * 60) }, label = { Text("${preset}m") }, ) } } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Button( - onClick = { - running = !running - if (running) startOverlayIfOn() - }, - enabled = remainingSeconds > 0, - ) { Text(if (running) "Pause" else "Start") } + onClick = { controller.toggle() }, + enabled = timer.remainingSeconds > 0, + ) { Text(if (timer.running) "Pause" else "Start") } + OutlinedButton(onClick = { controller.reset() }) { Text("Reset") } OutlinedButton( onClick = { - if (running || remainingSeconds < totalSeconds) onFinished(totalSeconds / 60, false) - running = false - remainingSeconds = totalSeconds - TimerOverlayService.hide(context) - overlayOn = false - }, - ) { Text("Reset") } - OutlinedButton( - onClick = { - if (!overlayOn && !Settings.canDrawOverlays(context)) { + if (!timer.overlayVisible && !Settings.canDrawOverlays(context)) { context.startActivity( Intent( Settings.ACTION_MANAGE_OVERLAY_PERMISSION, @@ -191,81 +198,37 @@ private fun TimerTab(onFinished: (minutes: Int, completed: Boolean) -> Unit) { ) return@OutlinedButton } - overlayOn = !overlayOn - if (overlayOn) startOverlayIfOn() else TimerOverlayService.hide(context) + controller.setOverlayVisible(!timer.overlayVisible) }, - ) { Text(if (overlayOn) "Hide overlay" else "Overlay") } + ) { Text(if (timer.overlayVisible) "Hide overlay" else "Overlay") } } Text( - "Tap inside the ring to set a custom time. Overlay floats the ring over any app.", + "Tap the time to type a new one. The timer keeps running when you leave this tab or the app. " + + "The overlay floats the ring over anything - drag it around, pinch to resize.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - - if (showCustom) { - CustomTimeDialog( - initialSeconds = totalSeconds, - onDismiss = { showCustom = false }, - onConfirm = { seconds -> - totalSeconds = seconds.coerceAtLeast(1) - remainingSeconds = totalSeconds - running = false - showCustom = false - }, - ) - } } private fun formatClock(seconds: Int): String = - if (seconds >= 3600) "%d:%02d:%02d".format(seconds / 3600, (seconds % 3600) / 60, seconds % 60) - else "%02d:%02d".format(seconds / 60, seconds % 60) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CustomTimeDialog( - initialSeconds: Int, - onDismiss: () -> Unit, - onConfirm: (Int) -> Unit, -) { - var hours by remember { mutableStateOf((initialSeconds / 3600).toString().takeIf { it != "0" } ?: "") } - var minutes by remember { mutableStateOf(((initialSeconds % 3600) / 60).toString()) } - var seconds by remember { mutableStateOf((initialSeconds % 60).toString()) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Set timer") }, - text = { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - TimeField(hours, "HH") { hours = it } - Text(":") - TimeField(minutes, "MM") { minutes = it } - Text(":") - TimeField(seconds, "SS") { seconds = it } - } - }, - confirmButton = { - Button(onClick = { - val h = hours.toIntOrNull() ?: 0 - val m = minutes.toIntOrNull() ?: 0 - val s = seconds.toIntOrNull() ?: 0 - onConfirm(h * 3600 + m * 60 + s) - }) { Text("Set") } - }, - dismissButton = { OutlinedButton(onClick = onDismiss) { Text("Cancel") } }, - ) -} + if (seconds >= 3600) { + "%d:%02d:%02d".format(seconds / 3600, (seconds % 3600) / 60, seconds % 60) + } else { + "%02d:%02d".format(seconds / 60, seconds % 60) + } -@Composable -private fun TimeField(value: String, label: String, onChange: (String) -> Unit) { - OutlinedTextField( - value = value, - onValueChange = { input -> onChange(input.filter { it.isDigit() }.take(2)) }, - placeholder = { Text(label) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.width(76.dp), - ) +/** Accepts "25", "25:00" and "1:05:00". */ +private fun parseClock(text: String): Int? { + val parts = text.split(':').map { it.trim() }.filter { it.isNotEmpty() } + if (parts.isEmpty()) return null + val numbers = parts.map { it.toIntOrNull() ?: return null } + val seconds = when (numbers.size) { + 1 -> numbers[0] * 60 + 2 -> numbers[0] * 60 + numbers[1] + else -> numbers[0] * 3600 + numbers[1] * 60 + numbers[2] + } + return seconds.takeIf { it > 0 } } @Composable diff --git a/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/FocusViewModel.kt b/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/FocusViewModel.kt index 76458a7..941eff5 100644 --- a/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/FocusViewModel.kt +++ b/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/FocusViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.viewModelScope import com.lifeos.core.common.viewmodel.LifeViewModel import com.lifeos.core.database.adhd.FocusDao import com.lifeos.core.database.adhd.FocusSessionEntity +import com.lifeos.feature.adhd.data.FocusTimerController import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import java.time.Instant @@ -27,6 +28,8 @@ sealed interface FocusUiEffect @HiltViewModel class FocusViewModel @Inject constructor( private val focusDao: FocusDao, + /** Shared so the countdown outlives this screen (§Module 5). */ + val timerController: FocusTimerController, ) : LifeViewModel(FocusUiState()) { init { diff --git a/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/data/FocusJarvisBridge.kt b/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/data/FocusJarvisBridge.kt new file mode 100644 index 0000000..1f12005 --- /dev/null +++ b/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/data/FocusJarvisBridge.kt @@ -0,0 +1,84 @@ +package com.lifeos.feature.adhd.data + +import com.lifeos.core.common.result.LifeError +import com.lifeos.core.common.result.LifeResult +import com.lifeos.core.database.adhd.FocusDao +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 javax.inject.Inject + +/** Focus as Jarvis reads it: the live timer plus the streak. */ +internal class FocusProvider @Inject constructor( + private val focusDao: FocusDao, + private val controller: FocusTimerController, +) : LifeDataProvider { + + override val topic: String = "focus" + override val description: String = "focus timer state and session streak" + + override suspend fun read(query: String?): String { + val timer = controller.state.value + val sessions = focusDao.observeRecent().first() + val done = sessions.count { it.completed } + return buildString { + appendLine( + if (timer.running) { + "Focus timer: running, ${timer.remainingSeconds / 60}m ${timer.remainingSeconds % 60}s left " + + "of ${timer.totalSeconds / 60}m." + } else { + "Focus timer: idle, set to ${timer.totalSeconds / 60}m." + }, + ) + appendLine("Overlay is ${if (timer.overlayVisible) "showing" else "hidden"}.") + appendLine("Sessions: $done completed of ${sessions.size} recorded.") + sessions.take(5).forEach { + appendLine("- ${it.minutes}m ${if (it.completed) "done" else "abandoned"}") + } + }.trim() + } +} + +/** Starting and stopping the focus timer on Jarvis's word. */ +internal class FocusActionHandler @Inject constructor( + private val controller: FocusTimerController, +) : LifeActionHandler { + + override fun canHandle(action: LifeAction): Boolean = + action is LifeAction.StartFocusTimer || action is LifeAction.StopFocusTimer + + override suspend fun execute(action: LifeAction): LifeResult = when (action) { + is LifeAction.StartFocusTimer -> { + val minutes = action.minutes.coerceIn(1, 24 * 60) + controller.setTotal(minutes * 60) + controller.start() + LifeResult.Success(null) + } + + is LifeAction.StopFocusTimer -> { + controller.reset() + LifeResult.Success(null) + } + + else -> LifeResult.Failure(LifeError.Validation("Unsupported action")) + } +} + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class FocusJarvisModule { + + @Binds + @IntoSet + abstract fun bindProvider(impl: FocusProvider): LifeDataProvider + + @Binds + @IntoSet + abstract fun bindHandler(impl: FocusActionHandler): LifeActionHandler +} diff --git a/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/data/FocusTimerController.kt b/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/data/FocusTimerController.kt new file mode 100644 index 0000000..aeb19d4 --- /dev/null +++ b/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/data/FocusTimerController.kt @@ -0,0 +1,176 @@ +package com.lifeos.feature.adhd.data + +import android.content.Context +import android.os.SystemClock +import android.os.VibrationEffect +import android.os.VibratorManager +import com.lifeos.core.database.adhd.FocusDao +import com.lifeos.core.database.adhd.FocusSessionEntity +import com.lifeos.feature.adhd.overlay.TimerOverlayState +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +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 + +/** The focus timer as the whole app sees it. */ +data class FocusTimerState( + val totalSeconds: Int = 25 * 60, + val remainingSeconds: Int = 25 * 60, + val running: Boolean = false, + val overlayVisible: Boolean = false, +) + +/** + * Focus timer that keeps running (§Module 5). + * + * The state lives in a singleton rather than in a composable, and the countdown + * is derived from an absolute deadline, so switching tabs, leaving for Home or + * letting the process idle no longer resets anything. The overlay reads the + * same deadline, which is why the two never drift apart. + */ +@Singleton +class FocusTimerController @Inject constructor( + @ApplicationContext private val context: Context, + private val focusDao: FocusDao, +) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var ticker: Job? = null + + /** Absolute end of the running stretch, on the elapsed-realtime clock. */ + private var deadlineElapsed: Long = 0L + + private val _state = MutableStateFlow(FocusTimerState()) + val state = _state.asStateFlow() + + init { + // The overlay's own close button hides the window; mirror that here so + // the in-app button label stays truthful. + scope.launch { + TimerOverlayState.visible.collect { visible -> + _state.value = _state.value.copy(overlayVisible = visible) + } + } + } + + fun setTotal(seconds: Int) { + val total = seconds.coerceIn(1, 24 * 3600) + stopTicker() + deadlineElapsed = 0L + _state.value = _state.value.copy(totalSeconds = total, remainingSeconds = total, running = false) + pushOverlay() + } + + fun toggle() { + if (_state.value.running) pause() else start() + } + + fun start() { + val current = _state.value + if (current.remainingSeconds <= 0 || current.running) return + deadlineElapsed = SystemClock.elapsedRealtime() + current.remainingSeconds * 1000L + _state.value = current.copy(running = true) + pushOverlay() + startTicker() + } + + fun pause() { + if (!_state.value.running) return + stopTicker() + _state.value = _state.value.copy(running = false, remainingSeconds = remainingFromDeadline()) + deadlineElapsed = 0L + pushOverlay() + } + + /** Resets to the configured length; records an abandoned session if it ran. */ + fun reset() { + val current = _state.value + val ran = current.running || current.remainingSeconds < current.totalSeconds + stopTicker() + deadlineElapsed = 0L + _state.value = current.copy(running = false, remainingSeconds = current.totalSeconds) + if (ran) recordSession(current.totalSeconds / 60, completed = false) + pushOverlay() + } + + fun setOverlayVisible(visible: Boolean) { + _state.value = _state.value.copy(overlayVisible = visible) + if (visible) pushOverlay() else TimerOverlayState.hide(context) + } + + private fun startTicker() { + stopTicker() + ticker = scope.launch { + while (true) { + val left = remainingFromDeadline() + _state.value = _state.value.copy(remainingSeconds = left) + if (left <= 0) { + finish() + return@launch + } + delay(500) + } + } + } + + private fun stopTicker() { + ticker?.cancel() + ticker = null + } + + private fun finish() { + val total = _state.value.totalSeconds + deadlineElapsed = 0L + _state.value = _state.value.copy(running = false, remainingSeconds = 0) + recordSession(total / 60, completed = true) + TimerOverlayState.hide(context) + _state.value = _state.value.copy(overlayVisible = false, remainingSeconds = total) + runCatching { + context.getSystemService(VibratorManager::class.java).defaultVibrator + .vibrate(VibrationEffect.createWaveform(longArrayOf(0, 200, 100, 200, 100, 400), -1)) + } + } + + private fun recordSession(minutes: Int, completed: Boolean) { + scope.launch { + focusDao.insert( + FocusSessionEntity( + minutes = minutes, + startedAt = System.currentTimeMillis() - minutes * 60_000L, + completed = completed, + ), + ) + } + } + + private fun remainingFromDeadline(): Int { + if (deadlineElapsed <= 0L) return _state.value.remainingSeconds + val ms = deadlineElapsed - SystemClock.elapsedRealtime() + return ((ms + 999) / 1000).coerceAtLeast(0L).toInt() + } + + /** Keeps the floating ring in step with whatever the timer is doing. */ + private fun pushOverlay() { + val current = _state.value + if (!current.overlayVisible) return + val deadline = if (current.running) { + deadlineElapsed + } else { + // Paused: hand the overlay a deadline that renders the frozen time. + SystemClock.elapsedRealtime() + current.remainingSeconds * 1000L + } + TimerOverlayState.show( + context = context, + deadlineElapsed = deadline, + totalMs = current.totalSeconds * 1000L, + running = current.running, + ) + } +} diff --git a/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/overlay/TimerOverlayService.kt b/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/overlay/TimerOverlayService.kt index cefa1b0..4d2c646 100644 --- a/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/overlay/TimerOverlayService.kt +++ b/feature/adhd/src/main/kotlin/com/lifeos/feature/adhd/overlay/TimerOverlayService.kt @@ -12,96 +12,196 @@ import android.graphics.Typeface import android.os.IBinder import android.os.SystemClock import android.view.Gravity +import android.view.MotionEvent import android.view.View import android.view.WindowManager import android.widget.FrameLayout import android.widget.TextView +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlin.math.abs +import kotlin.math.hypot + +/** + * Whether the floating ring is on screen, and the only way to ask for it. + * + * The service publishes here, so the in-app "Overlay"/"Hide overlay" button + * still tells the truth after the overlay's own X is tapped. + */ +object TimerOverlayState { + + private val _visible = MutableStateFlow(false) + val visible = _visible.asStateFlow() + + /** Place and size the user dragged the ring to; survives hide and show. */ + internal var sizePx: Int = 0 + internal var posX: Int = Int.MIN_VALUE + internal var posY: Int = Int.MIN_VALUE + + internal fun publish(visible: Boolean) { + _visible.value = visible + } + + fun show(context: Context, deadlineElapsed: Long, totalMs: Long, running: Boolean) { + context.startService( + Intent(context, TimerOverlayService::class.java) + .putExtra(TimerOverlayService.EXTRA_DEADLINE_ELAPSED, deadlineElapsed) + .putExtra(TimerOverlayService.EXTRA_TOTAL_MS, totalMs) + .putExtra(TimerOverlayService.EXTRA_RUNNING, running), + ) + } + + fun hide(context: Context) { + context.startService( + Intent(context, TimerOverlayService::class.java).setAction(TimerOverlayService.ACTION_HIDE), + ) + } +} /** * Floats the Focus countdown ring over the whole OS (§Module 5). Started with - * a deadline; draws the shrinking ring itself (no Compose in a window token). - * A single tap toggles a close "X" that dismisses the overlay WITHOUT stopping - * the timer — the deadline is absolute, so reopening shows the correct time. + * an absolute deadline; draws the shrinking ring itself (no Compose in a window + * token). Drag it anywhere, pinch it bigger or smaller, and a single tap toggles + * a close "X" that dismisses the ring WITHOUT stopping the timer. */ class TimerOverlayService : Service() { private var view: TimerOverlayView? = null + private var params: WindowManager.LayoutParams? = null + + private val windowManager by lazy { getSystemService(Context.WINDOW_SERVICE) as WindowManager } override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent?.action == ACTION_HIDE) { remove() + TimerOverlayState.publish(false) stopSelf() return START_NOT_STICKY } val deadline = intent?.getLongExtra(EXTRA_DEADLINE_ELAPSED, 0L) ?: 0L val total = intent?.getLongExtra(EXTRA_TOTAL_MS, 0L) ?: 0L - if (deadline <= 0L) { stopSelf(); return START_NOT_STICKY } - show(deadline, total) + val running = intent?.getBooleanExtra(EXTRA_RUNNING, true) ?: true + if (deadline <= 0L) { + stopSelf() + return START_NOT_STICKY + } + val existing = view + if (existing != null) { + // Already up: retarget it, keeping the user's place and size. + existing.retarget(deadline, total, running) + } else { + show(deadline, total, running) + } + TimerOverlayState.publish(true) return START_NOT_STICKY } - private fun show(deadlineElapsed: Long, totalMs: Long) { + private fun show(deadlineElapsed: Long, totalMs: Long, running: Boolean) { remove() - val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager - val overlay = TimerOverlayView(this, deadlineElapsed, totalMs) { remove(); stopSelf() } - val size = (resources.displayMetrics.density * 180).toInt() - val params = WindowManager.LayoutParams( + val density = resources.displayMetrics.density + val size = TimerOverlayState.sizePx.takeIf { it > 0 } ?: (density * 180).toInt() + val layout = WindowManager.LayoutParams( size, size, WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, PixelFormat.TRANSLUCENT, ).apply { - gravity = Gravity.TOP or Gravity.END - x = (resources.displayMetrics.density * 16).toInt() - y = (resources.displayMetrics.density * 96).toInt() + gravity = Gravity.TOP or Gravity.START + x = TimerOverlayState.posX.takeIf { it != Int.MIN_VALUE } + ?: (resources.displayMetrics.widthPixels - size - (density * 16).toInt()) + y = TimerOverlayState.posY.takeIf { it != Int.MIN_VALUE } ?: (density * 96).toInt() } - windowManager.addView(overlay, params) + val overlay = TimerOverlayView( + context = this, + deadlineElapsed = deadlineElapsed, + totalMs = totalMs, + running = running, + minSizePx = (density * 96).toInt(), + maxSizePx = (density * 320).toInt(), + onClose = { + remove() + TimerOverlayState.publish(false) + stopSelf() + }, + onMove = { dx, dy -> + val current = params + if (current != null) { + current.x = (current.x + dx) + .coerceIn(0, (resources.displayMetrics.widthPixels - current.width).coerceAtLeast(0)) + current.y = (current.y + dy) + .coerceIn(0, (resources.displayMetrics.heightPixels - current.height).coerceAtLeast(0)) + TimerOverlayState.posX = current.x + TimerOverlayState.posY = current.y + runCatching { windowManager.updateViewLayout(view, current) } + } + }, + onResize = { newSize -> + val current = params + if (current != null) { + current.width = newSize + current.height = newSize + TimerOverlayState.sizePx = newSize + runCatching { windowManager.updateViewLayout(view, current) } + } + }, + ) + windowManager.addView(overlay, layout) view = overlay + params = layout } private fun remove() { - view?.let { (getSystemService(Context.WINDOW_SERVICE) as WindowManager).removeView(it) } + view?.let { runCatching { windowManager.removeView(it) } } view = null + params = null } override fun onDestroy() { remove() + TimerOverlayState.publish(false) super.onDestroy() } companion object { const val ACTION_HIDE = "com.lifeos.adhd.HIDE_TIMER_OVERLAY" - private const val EXTRA_DEADLINE_ELAPSED = "deadline_elapsed" - private const val EXTRA_TOTAL_MS = "total_ms" - - fun show(context: Context, deadlineElapsed: Long, totalMs: Long) { - context.startService( - Intent(context, TimerOverlayService::class.java) - .putExtra(EXTRA_DEADLINE_ELAPSED, deadlineElapsed) - .putExtra(EXTRA_TOTAL_MS, totalMs), - ) - } - - fun hide(context: Context) { - context.startService( - Intent(context, TimerOverlayService::class.java).setAction(ACTION_HIDE), - ) - } + internal const val EXTRA_DEADLINE_ELAPSED = "deadline_elapsed" + internal const val EXTRA_TOTAL_MS = "total_ms" + internal const val EXTRA_RUNNING = "running" } } -/** Self-drawing countdown ring. Tap once to toggle a close button. */ +/** + * Self-drawing countdown ring: drag to move, pinch to resize, tap to reveal the + * close button. + */ private class TimerOverlayView( context: Context, - private val deadlineElapsed: Long, - private val totalMs: Long, + deadlineElapsed: Long, + totalMs: Long, + running: Boolean, + private val minSizePx: Int, + private val maxSizePx: Int, private val onClose: () -> Unit, + private val onMove: (dx: Int, dy: Int) -> Unit, + private val onResize: (sizePx: Int) -> Unit, ) : FrameLayout(context) { + private var deadlineElapsed = deadlineElapsed + private var totalMs = totalMs + private var running = running + private var showClose = false + private var lastX = 0f + private var lastY = 0f + private var downX = 0f + private var downY = 0f + private var dragging = false + private var pinchStart = 0f + private var pinchStartSize = 0 + private val closeButton = TextView(context).apply { text = "X" setTextColor(Color.WHITE) @@ -115,20 +215,17 @@ private class TimerOverlayView( private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE - strokeWidth = resources.displayMetrics.density * 10 strokeCap = Paint.Cap.ROUND color = Color.parseColor("#3A4048") } private val barPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE - strokeWidth = resources.displayMetrics.density * 10 strokeCap = Paint.Cap.ROUND color = Color.parseColor("#9FCBA6") } private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE textAlign = Paint.Align.CENTER - textSize = resources.displayMetrics.density * 26 typeface = Typeface.DEFAULT_BOLD } private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#14181C") } @@ -138,18 +235,84 @@ private class TimerOverlayView( closeButton, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.TOP or Gravity.END), ) - setOnClickListener { - showClose = !showClose - closeButton.visibility = if (showClose) View.VISIBLE else View.GONE - } setWillNotDraw(false) } + fun retarget(deadlineElapsed: Long, totalMs: Long, running: Boolean) { + this.deadlineElapsed = deadlineElapsed + this.totalMs = totalMs + this.running = running + invalidate() + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downX = event.rawX + downY = event.rawY + lastX = event.rawX + lastY = event.rawY + dragging = false + return true + } + + MotionEvent.ACTION_POINTER_DOWN -> { + if (event.pointerCount == 2) { + pinchStart = spacing(event) + pinchStartSize = width + } + return true + } + + MotionEvent.ACTION_MOVE -> { + if (event.pointerCount >= 2 && pinchStart > 0f) { + val scale = spacing(event) / pinchStart + onResize((pinchStartSize * scale).toInt().coerceIn(minSizePx, maxSizePx)) + return true + } + val dx = event.rawX - lastX + val dy = event.rawY - lastY + if (!dragging && hypot(event.rawX - downX, event.rawY - downY) > TOUCH_SLOP) dragging = true + if (dragging) { + lastX = event.rawX + lastY = event.rawY + onMove(dx.toInt(), dy.toInt()) + } + return true + } + + MotionEvent.ACTION_POINTER_UP -> { + pinchStart = 0f + return true + } + + MotionEvent.ACTION_UP -> { + // A tap (not a drag) toggles the close button. + if (!dragging && abs(event.rawX - downX) < TOUCH_SLOP && abs(event.rawY - downY) < TOUCH_SLOP) { + showClose = !showClose + closeButton.visibility = if (showClose) View.VISIBLE else View.GONE + } + dragging = false + pinchStart = 0f + return true + } + } + return super.onTouchEvent(event) + } + + private fun spacing(event: MotionEvent): Float = + hypot(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1)) + override fun onDraw(canvas: Canvas) { val remaining = (deadlineElapsed - SystemClock.elapsedRealtime()).coerceAtLeast(0L) val cx = width / 2f val cy = height / 2f - val radius = minOf(width, height) / 2f - trackPaint.strokeWidth + // Stroke and label scale with the window, so resizing stays readable. + val stroke = (minOf(width, height) * 0.055f).coerceAtLeast(4f) + trackPaint.strokeWidth = stroke + barPaint.strokeWidth = stroke + textPaint.textSize = minOf(width, height) * 0.2f + val radius = minOf(width, height) / 2f - stroke canvas.drawCircle(cx, cy, radius, bgPaint) val rect = RectF(cx - radius, cy - radius, cx + radius, cy + radius) canvas.drawArc(rect, -90f, 360f, false, trackPaint) @@ -163,6 +326,10 @@ private class TimerOverlayView( } val ty = cy - (textPaint.descent() + textPaint.ascent()) / 2 canvas.drawText(label, cx, ty, textPaint) - if (remaining > 0) postInvalidateDelayed(250) + if (running && remaining > 0) postInvalidateDelayed(250) + } + + private companion object { + const val TOUCH_SLOP = 16f } } diff --git a/feature/agentic/build.gradle.kts b/feature/agentic/build.gradle.kts index de2532b..b08d47f 100644 --- a/feature/agentic/build.gradle.kts +++ b/feature/agentic/build.gradle.kts @@ -7,6 +7,7 @@ plugins { dependencies { implementation(projects.core.common) + implementation(projects.core.service) implementation(projects.core.designsystem) implementation(projects.core.database) implementation(projects.core.ai) diff --git a/feature/agentic/src/main/kotlin/com/lifeos/feature/agentic/MacrosScreen.kt b/feature/agentic/src/main/kotlin/com/lifeos/feature/agentic/MacrosScreen.kt index dad4e71..40d8c38 100644 --- a/feature/agentic/src/main/kotlin/com/lifeos/feature/agentic/MacrosScreen.kt +++ b/feature/agentic/src/main/kotlin/com/lifeos/feature/agentic/MacrosScreen.kt @@ -12,16 +12,22 @@ 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.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator 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 @@ -33,14 +39,17 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.lifeos.core.ai.macro.MacroCatalog +import com.lifeos.core.ai.macro.MacroStep import com.lifeos.core.designsystem.component.EmptyState /** NL macro authoring + dry-run preview + run (§Module 12, [src 41]). */ @@ -68,6 +77,11 @@ fun MacrosRoute(viewModel: MacrosViewModel = hiltViewModel()) { onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } + uiState.draft?.let { draft -> + MacroEditorScreen(draft = draft, uiState = uiState, onEvent = viewModel::onEvent) + return + } + uiState.detail?.let { macro -> MacroDetailScreen(macro = macro, uiState = uiState, onEvent = viewModel::onEvent) return @@ -75,12 +89,20 @@ fun MacrosRoute(viewModel: MacrosViewModel = hiltViewModel()) { Scaffold( topBar = { TopAppBar(title = { Text("Macros") }) }, + floatingActionButton = { + FloatingActionButton(onClick = { viewModel.onEvent(MacrosUiEvent.NewMacro) }) { + Icon(Icons.Filled.Add, contentDescription = "New macro") + } + }, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { innerPadding -> Column( modifier = Modifier .padding(innerPadding) - .padding(horizontal = 16.dp), + // Breathing room under the app bar: the field's label used to + // ride up into it. + .padding(horizontal = 16.dp) + .padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { if (!uiState.serviceEnabled) { @@ -100,23 +122,30 @@ fun MacrosRoute(viewModel: MacrosViewModel = hiltViewModel()) { OutlinedTextField( value = uiState.nlPrompt, onValueChange = { viewModel.onEvent(MacrosUiEvent.PromptChanged(it)) }, - label = { Text("Describe an automation, e.g. \"open Spotify and tap Search\"") }, - minLines = 2, + label = { Text("Create with Jarvis") }, + placeholder = { Text("e.g. open Spotify") }, + singleLine = true, modifier = Modifier.fillMaxWidth(), ) - Button( - onClick = { viewModel.onEvent(MacrosUiEvent.Compile) }, - enabled = uiState.nlPrompt.isNotBlank() && !uiState.compiling, - modifier = Modifier.fillMaxWidth(), - ) { - Text(if (uiState.compiling) "Compiling…" else "Compile with AI") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { viewModel.onEvent(MacrosUiEvent.Compile) }, + enabled = uiState.nlPrompt.isNotBlank() && !uiState.compiling, + modifier = Modifier.weight(1f), + ) { + Text(if (uiState.compiling) "Compiling…" else "Compile with AI") + } + OutlinedButton(onClick = { viewModel.onEvent(MacrosUiEvent.NewMacro) }) { + Text("Build by hand") + } } if (uiState.running) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) if (uiState.macros.isEmpty()) { EmptyState( title = "No macros yet", - description = "Describe an automation above — you review every step before it can run.", + description = "Describe one above, or tap + to build it step by step. " + + "Nothing runs until you say so.", ) return@Column } @@ -145,6 +174,9 @@ fun MacrosRoute(viewModel: MacrosViewModel = hiltViewModel()) { ) { Icon(Icons.Filled.PlayArrow, contentDescription = "Run") } + IconButton(onClick = { viewModel.onEvent(MacrosUiEvent.EditMacro(macro)) }) { + Icon(Icons.Filled.Edit, contentDescription = "Edit") + } IconButton(onClick = { viewModel.onEvent(MacrosUiEvent.Delete(macro.id)) }) { Icon(Icons.Filled.Delete, contentDescription = "Delete") } @@ -158,7 +190,7 @@ fun MacrosRoute(viewModel: MacrosViewModel = hiltViewModel()) { uiState.preview?.let { steps -> AlertDialog( onDismissRequest = { viewModel.onEvent(MacrosUiEvent.DiscardPreview) }, - title = { Text("Dry run — confirm the steps") }, + title = { Text("Compiled steps") }, text = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { steps.forEachIndexed { index, step -> @@ -170,14 +202,14 @@ fun MacrosRoute(viewModel: MacrosViewModel = hiltViewModel()) { } Spacer(modifier = Modifier.padding(2.dp)) Text( - "Nothing runs until you press Run on the saved macro.", + "Opens in the editor next, so you can fix or extend anything before saving.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, confirmButton = { - TextButton(onClick = { viewModel.onEvent(MacrosUiEvent.SavePreview) }) { Text("Save macro") } + TextButton(onClick = { viewModel.onEvent(MacrosUiEvent.SavePreview) }) { Text("Open in editor") } }, dismissButton = { TextButton(onClick = { viewModel.onEvent(MacrosUiEvent.DiscardPreview) }) { Text("Discard") } @@ -222,6 +254,9 @@ private fun MacroDetailScreen( } }, actions = { + IconButton(onClick = { onEvent(MacrosUiEvent.EditMacro(macro)) }) { + Icon(Icons.Filled.Edit, contentDescription = "Edit steps") + } IconButton(onClick = { onEvent(MacrosUiEvent.Delete(macro.id)) }) { Icon(Icons.Filled.Delete, contentDescription = "Delete") } @@ -285,3 +320,243 @@ private fun MacroDetailScreen( } } } + +/** + * Macro editor (§Module 12): build a macro by hand, or fix one the AI wrote. + * Steps are picked from the same catalogue the executor implements, so nothing + * here can compile to an action that then fails at run time. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MacroEditorScreen( + draft: MacroDraft, + uiState: MacrosUiState, + onEvent: (MacrosUiEvent) -> Unit, +) { + var showPicker by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (draft.id == 0L) "New macro" else "Edit macro") }, + navigationIcon = { + IconButton(onClick = { onEvent(MacrosUiEvent.CloseDraft) }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + TextButton(onClick = { onEvent(MacrosUiEvent.SaveDraft) }) { Text("Save") } + }, + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { showPicker = true }) { + Icon(Icons.Filled.Add, contentDescription = "Add step") + } + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .padding(horizontal = 16.dp) + .padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutlinedTextField( + value = draft.name, + onValueChange = { onEvent(MacrosUiEvent.DraftNameChanged(it)) }, + label = { Text("Macro name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { onEvent(MacrosUiEvent.TestDraft) }, + enabled = draft.steps.isNotEmpty() && !uiState.running, + ) { Text(if (uiState.running) "Running…" else "Test now") } + OutlinedButton(onClick = { showPicker = true }) { Text("Add step") } + } + if (uiState.running) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + + if (draft.steps.isEmpty()) { + Text( + "No steps yet. Add one: open an app, tap something, type, wait, swipe, toggle the " + + "torch, control media, or fire a LifeOS action.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(draft.steps.size) { index -> + StepCard( + index = index, + step = draft.steps[index], + lastIndex = draft.steps.lastIndex, + appSuggestions = uiState.installedApps, + onEvent = onEvent, + ) + } + } + } + } + + if (showPicker) { + ActionPickerDialog( + onDismiss = { showPicker = false }, + onPick = { action -> + onEvent(MacrosUiEvent.AddStep(action)) + showPicker = false + }, + ) + } +} + +/** One editable step: its argument field, reorder arrows and a delete button. */ +@Composable +private fun StepCard( + index: Int, + step: MacroStep, + lastIndex: Int, + appSuggestions: List, + onEvent: (MacrosUiEvent) -> Unit, +) { + val spec = MacroCatalog.byAction[step.action] + Card { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${index + 1}. ${spec?.label ?: step.action}", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { onEvent(MacrosUiEvent.MoveStep(index, -1)) }, + enabled = index > 0, + ) { Icon(Icons.Filled.KeyboardArrowUp, contentDescription = "Move up") } + IconButton( + onClick = { onEvent(MacrosUiEvent.MoveStep(index, +1)) }, + enabled = index < lastIndex, + ) { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = "Move down") } + IconButton(onClick = { onEvent(MacrosUiEvent.RemoveStep(index)) }) { + Icon(Icons.Filled.Delete, contentDescription = "Remove step") + } + } + when (spec?.arg) { + com.lifeos.core.ai.macro.MacroArg.TARGET -> { + OutlinedTextField( + value = step.target.orEmpty(), + onValueChange = { onEvent(MacrosUiEvent.UpdateStep(index, step.copy(target = it))) }, + label = { Text(spec.hint.ifBlank { "Target" }) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + if (step.action == "LAUNCH" && step.target.orEmpty().length >= 2) { + val matches = appSuggestions.filter { + it.contains(step.target.orEmpty(), ignoreCase = true) && + !it.equals(step.target.orEmpty(), ignoreCase = true) + }.take(4) + if (matches.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + matches.forEach { label -> + TextButton( + onClick = { + onEvent(MacrosUiEvent.UpdateStep(index, step.copy(target = label))) + }, + ) { Text(label, style = MaterialTheme.typography.bodySmall) } + } + } + } + } + } + + com.lifeos.core.ai.macro.MacroArg.TEXT -> OutlinedTextField( + value = step.text.orEmpty(), + onValueChange = { onEvent(MacrosUiEvent.UpdateStep(index, step.copy(text = it))) }, + label = { Text(spec.hint.ifBlank { "Text" }) }, + modifier = Modifier.fillMaxWidth(), + ) + + com.lifeos.core.ai.macro.MacroArg.DELAY -> OutlinedTextField( + value = step.delayMs?.toString().orEmpty(), + onValueChange = { value -> + val digits = value.filter { it.isDigit() }.take(7) + onEvent(MacrosUiEvent.UpdateStep(index, step.copy(delayMs = digits.toLongOrNull()))) + }, + label = { Text(spec.hint.ifBlank { "Milliseconds" }) }, + singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = androidx.compose.ui.text.input.KeyboardType.Number, + ), + modifier = Modifier.fillMaxWidth(), + ) + + else -> if (spec == null) { + Text( + "Unknown action \"${step.action}\" — delete this step.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +/** Every action the executor supports, grouped, with a search box. */ +@Composable +private fun ActionPickerDialog(onDismiss: () -> Unit, onPick: (String) -> Unit) { + var query by remember { mutableStateOf("") } + val matches = MacroCatalog.actions.filter { + query.isBlank() || + it.label.contains(query, ignoreCase = true) || + it.action.contains(query, ignoreCase = true) || + it.group.contains(query, ignoreCase = true) + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add a step") }, + text = { + Column { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + label = { Text("Search actions") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn(modifier = Modifier.padding(top = 8.dp)) { + MacroCatalog.groups.forEach { group -> + val inGroup = matches.filter { it.group == group } + if (inGroup.isEmpty()) return@forEach + item(key = "group-$group") { + Text( + group, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(vertical = 6.dp), + ) + } + items(inGroup.size, key = { "action-$group-$it" }) { position -> + val spec = inGroup[position] + TextButton( + onClick = { onPick(spec.action) }, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text(spec.label) + if (spec.hint.isNotBlank()) { + Text( + spec.hint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } }, + ) +} diff --git a/feature/agentic/src/main/kotlin/com/lifeos/feature/agentic/MacrosViewModel.kt b/feature/agentic/src/main/kotlin/com/lifeos/feature/agentic/MacrosViewModel.kt index b6d9047..ddd195e 100644 --- a/feature/agentic/src/main/kotlin/com/lifeos/feature/agentic/MacrosViewModel.kt +++ b/feature/agentic/src/main/kotlin/com/lifeos/feature/agentic/MacrosViewModel.kt @@ -2,6 +2,11 @@ package com.lifeos.feature.agentic import android.content.Context import androidx.lifecycle.viewModelScope +import com.lifeos.core.ai.macro.MacroArg +import com.lifeos.core.ai.macro.MacroCatalog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import android.content.Intent import com.lifeos.core.ai.macro.MacroCompiler import com.lifeos.core.ai.macro.MacroStep import com.lifeos.core.common.result.LifeResult @@ -18,8 +23,20 @@ import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import javax.inject.Inject +/** A macro being written by hand or edited (§Module 12). */ +data class MacroDraft( + val id: Long = 0, + val name: String = "", + val steps: List = emptyList(), + val nlPrompt: String = "", +) + data class MacrosUiState( val macros: List = emptyList(), + /** Open editor, for a new macro or an existing one. */ + val draft: MacroDraft? = null, + /** Installed app labels, offered when a step needs an app. */ + val installedApps: List = emptyList(), val nlPrompt: String = "", val compiling: Boolean = false, /** Compiled-but-unsaved preview (dry run) the user must confirm. */ @@ -33,6 +50,19 @@ data class MacrosUiState( sealed interface MacrosUiEvent { data class PromptChanged(val value: String) : MacrosUiEvent + /** Opens an empty editor (the "+" button). */ + data object NewMacro : MacrosUiEvent + /** Opens the editor on an existing macro, however it was created. */ + data class EditMacro(val macro: MacroEntity) : MacrosUiEvent + data class DraftNameChanged(val value: String) : MacrosUiEvent + data class AddStep(val action: String) : MacrosUiEvent + data class UpdateStep(val index: Int, val step: MacroStep) : MacrosUiEvent + data class RemoveStep(val index: Int) : MacrosUiEvent + data class MoveStep(val index: Int, val delta: Int) : MacrosUiEvent + data object SaveDraft : MacrosUiEvent + data object CloseDraft : MacrosUiEvent + /** Runs whatever is in the editor without saving it. */ + data object TestDraft : MacrosUiEvent data object Compile : MacrosUiEvent data object SavePreview : MacrosUiEvent data object DiscardPreview : MacrosUiEvent @@ -68,14 +98,76 @@ class MacrosViewModel @Inject constructor( updateState { it.copy(macros = macros, serviceEnabled = serviceOn()) } } } + viewModelScope.launch { + val labels = withContext(Dispatchers.IO) { + val pm = context.packageManager + pm.queryIntentActivities( + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), + 0, + ) + .mapNotNull { it.activityInfo?.applicationInfo } + .distinctBy { it.packageName } + .map { runCatching { pm.getApplicationLabel(it).toString() }.getOrDefault(it.packageName) } + .sorted() + } + updateState { it.copy(installedApps = labels) } + } } override fun onEvent(event: MacrosUiEvent) { when (event) { is MacrosUiEvent.PromptChanged -> updateState { it.copy(nlPrompt = event.value) } + MacrosUiEvent.NewMacro -> updateState { it.copy(draft = MacroDraft()) } + is MacrosUiEvent.EditMacro -> updateState { + it.copy( + detail = null, + draft = MacroDraft( + id = event.macro.id, + name = event.macro.name, + steps = decode(event.macro.stepsJson), + nlPrompt = event.macro.nlPrompt, + ), + ) + } + is MacrosUiEvent.DraftNameChanged -> updateDraft { it.copy(name = event.value) } + is MacrosUiEvent.AddStep -> updateDraft { draft -> + draft.copy(steps = draft.steps + MacroStep(action = event.action)) + } + is MacrosUiEvent.UpdateStep -> updateDraft { draft -> + draft.copy( + steps = draft.steps.toMutableList().also { list -> + if (event.index in list.indices) list[event.index] = event.step + }, + ) + } + is MacrosUiEvent.RemoveStep -> updateDraft { draft -> + draft.copy(steps = draft.steps.filterIndexed { index, _ -> index != event.index }) + } + is MacrosUiEvent.MoveStep -> updateDraft { draft -> + val to = event.index + event.delta + if (event.index !in draft.steps.indices || to !in draft.steps.indices) { + draft + } else { + draft.copy( + steps = draft.steps.toMutableList().also { list -> + list.add(to, list.removeAt(event.index)) + }, + ) + } + } + MacrosUiEvent.CloseDraft -> updateState { it.copy(draft = null) } + MacrosUiEvent.SaveDraft -> viewModelScope.launch { saveDraft() } + MacrosUiEvent.TestDraft -> viewModelScope.launch { + val steps = uiState.value.draft?.steps.orEmpty() + if (steps.isEmpty()) { + updateState { it.copy(message = "Add a step first") } + } else { + runSteps(steps, macro = null) + } + } MacrosUiEvent.Compile -> viewModelScope.launch { updateState { it.copy(compiling = true) } - val result = macroCompiler.compile(uiState.value.nlPrompt) + val result = macroCompiler.compile(uiState.value.nlPrompt, uiState.value.installedApps) when (result) { is LifeResult.Success -> updateState { it.copy(compiling = false, preview = result.value) } is LifeResult.Failure -> updateState { @@ -91,46 +183,26 @@ class MacrosViewModel @Inject constructor( ), ) } - MacrosUiEvent.SavePreview -> viewModelScope.launch { + MacrosUiEvent.SavePreview -> { + // Compiled steps land in the editor, so a wrong guess can be + // fixed before it is ever saved or run. val state = uiState.value - val steps = state.preview ?: return@launch - macroDao.insert( - MacroEntity( - name = state.nlPrompt.take(60).ifBlank { "Macro" }, - nlPrompt = state.nlPrompt, - stepsJson = json.encodeToString(ListSerializer(MacroStep.serializer()), steps), - createdAt = System.currentTimeMillis(), - ), - ) - updateState { it.copy(preview = null, nlPrompt = "", message = "Macro saved") } + val steps = state.preview.orEmpty() + updateState { + it.copy( + preview = null, + draft = MacroDraft( + name = state.nlPrompt.take(60).ifBlank { "Macro" }, + steps = steps, + nlPrompt = state.nlPrompt, + ), + nlPrompt = "", + ) + } } MacrosUiEvent.DiscardPreview -> updateState { it.copy(preview = null) } is MacrosUiEvent.Run -> viewModelScope.launch { - val service = LifeAccessibilityService.instance - if (service == null) { - val enabledInSettings = LifeAccessibilityService.isEnabledInSettings(context) - updateState { - it.copy( - serviceEnabled = enabledInSettings, - message = if (enabledInSettings) { - "Service enabled but not connected yet — toggle \"LifeOS Macros\" off and on once" - } else { - "Enable \"LifeOS Macros\" in accessibility settings first" - }, - ) - } - return@launch - } - val steps = try { - json.decodeFromString(ListSerializer(MacroStep.serializer()), event.macro.stepsJson) - } catch (t: Throwable) { - updateState { it.copy(message = "Macro is corrupted: ${t.message}") } - return@launch - } - updateState { it.copy(running = true) } - val failure = service.run(steps) - macroDao.update(event.macro.copy(lastRunAt = System.currentTimeMillis())) - updateState { it.copy(running = false, message = failure ?: "Macro finished") } + runSteps(decode(event.macro.stepsJson), event.macro) } is MacrosUiEvent.ToggleEnabled -> viewModelScope.launch { macroDao.update(event.macro.copy(enabled = !event.macro.enabled)) @@ -138,6 +210,7 @@ class MacrosViewModel @Inject constructor( is MacrosUiEvent.Delete -> viewModelScope.launch { macroDao.delete(event.id) if (uiState.value.detail?.id == event.id) updateState { it.copy(detail = null) } + if (uiState.value.draft?.id == event.id) updateState { it.copy(draft = null) } } is MacrosUiEvent.OpenDetail -> updateState { it.copy(detail = event.macro) } MacrosUiEvent.CloseDetail -> updateState { it.copy(detail = null) } @@ -152,4 +225,79 @@ class MacrosViewModel @Inject constructor( MacrosUiEvent.DismissMessage -> updateState { it.copy(message = null) } } } + + private fun updateDraft(transform: (MacroDraft) -> MacroDraft) { + updateState { state -> state.copy(draft = state.draft?.let(transform)) } + } + + private fun decode(stepsJson: String): List = runCatching { + json.decodeFromString(ListSerializer(MacroStep.serializer()), stepsJson) + }.getOrDefault(emptyList()) + + private suspend fun saveDraft() { + val draft = uiState.value.draft ?: return + if (draft.steps.isEmpty()) { + updateState { it.copy(message = "A macro needs at least one step") } + return + } + val missing = draft.steps.withIndex().firstOrNull { (_, step) -> + when (MacroCatalog.byAction[step.action]?.arg) { + MacroArg.TARGET -> step.target.isNullOrBlank() + MacroArg.TEXT -> step.text.isNullOrBlank() + MacroArg.DELAY -> step.delayMs == null + else -> false + } + } + if (missing != null) { + updateState { + it.copy(message = "Step ${missing.index + 1} (${missing.value.action}) still needs a value") + } + return + } + val stepsJson = json.encodeToString(ListSerializer(MacroStep.serializer()), draft.steps) + val name = draft.name.trim().ifBlank { "Macro" } + if (draft.id == 0L) { + macroDao.insert( + MacroEntity( + name = name, + nlPrompt = draft.nlPrompt, + stepsJson = stepsJson, + createdAt = System.currentTimeMillis(), + ), + ) + } else { + val existing = macroDao.byId(draft.id) + if (existing != null) { + macroDao.update(existing.copy(name = name, stepsJson = stepsJson)) + } + } + updateState { it.copy(draft = null, message = "Macro saved") } + } + + /** Shared by Run and the editor's Test button. */ + private suspend fun runSteps(steps: List, macro: MacroEntity?) { + val service = LifeAccessibilityService.instance + if (service == null) { + val enabledInSettings = LifeAccessibilityService.isEnabledInSettings(context) + updateState { + it.copy( + serviceEnabled = enabledInSettings, + message = if (enabledInSettings) { + "Service enabled but not connected yet — toggle \"LifeOS Macros\" off and on once" + } else { + "Enable \"LifeOS Macros\" in accessibility settings first" + }, + ) + } + return + } + if (steps.isEmpty()) { + updateState { it.copy(message = "That macro has no steps") } + return + } + updateState { it.copy(running = true) } + val failure = service.run(steps) + macro?.let { macroDao.update(it.copy(lastRunAt = System.currentTimeMillis())) } + updateState { it.copy(running = false, message = failure ?: "Macro finished") } + } } diff --git a/feature/agentic/src/main/kotlin/com/lifeos/feature/agentic/engine/LifeAccessibilityService.kt b/feature/agentic/src/main/kotlin/com/lifeos/feature/agentic/engine/LifeAccessibilityService.kt index 5469756..5e6b2ee 100644 --- a/feature/agentic/src/main/kotlin/com/lifeos/feature/agentic/engine/LifeAccessibilityService.kt +++ b/feature/agentic/src/main/kotlin/com/lifeos/feature/agentic/engine/LifeAccessibilityService.kt @@ -6,6 +6,11 @@ import android.os.Bundle import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import com.lifeos.core.ai.macro.MacroStep +import com.lifeos.core.common.result.LifeResult +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 com.lifeos.core.common.log.LifeLogger import kotlinx.coroutines.delay @@ -14,8 +19,16 @@ import kotlinx.coroutines.delay * "LifeOS Macros" in system accessibility settings; steps run exclusively * from an explicit Run tap in the app — never on events. */ +@dagger.hilt.android.AndroidEntryPoint class LifeAccessibilityService : AccessibilityService() { + /** Lets LIFEOS_* steps act through the normal cross-module contract. */ + @javax.inject.Inject + lateinit var dispatcherHolder: LifeActionDispatcher + + private val actionDispatcher: LifeActionDispatcher? + get() = if (::dispatcherHolder.isInitialized) dispatcherHolder else null + override fun onServiceConnected() { super.onServiceConnected() instance = this @@ -33,34 +46,295 @@ class LifeAccessibilityService : AccessibilityService() { /** Runs validated IR steps sequentially; returns a failure description or null. */ suspend fun run(steps: List): String? { steps.forEachIndexed { index, step -> - val failure = when (step.action) { - "LAUNCH" -> launchApp(step.target.orEmpty()) - "CLICK" -> clickText(step.target.orEmpty()) - "INPUT" -> inputText(step.text.orEmpty()) - "BACK" -> if (performGlobalAction(GLOBAL_ACTION_BACK)) null else "BACK failed" - "HOME" -> if (performGlobalAction(GLOBAL_ACTION_HOME)) null else "HOME failed" - "WAIT" -> { - delay(step.delayMs ?: 1_000) - null - } - else -> "Unsupported action ${step.action}" - } + val failure = perform(step) if (failure != null) return "Step ${index + 1} (${step.action}): $failure" - delay(350) + delay(if (step.action == "WAIT" || step.action == "WAIT_FOR") 0 else 350) } return null } + @Suppress("CyclomaticComplexMethod", "LongMethod") + private suspend fun perform(step: MacroStep): String? = when (step.action) { + // ---- apps and navigation ------------------------------------------ + "LAUNCH" -> launchApp(step.target.orEmpty()) + "LAUNCH_URL" -> openUri(step.target.orEmpty()) + "OPEN_SETTINGS" -> openSettings(step.target.orEmpty()) + "DIAL" -> openUri("tel:" + step.target.orEmpty().filter { it.isDigit() || it == '+' }) + "BACK" -> global(GLOBAL_ACTION_BACK, "BACK") + "HOME" -> global(GLOBAL_ACTION_HOME, "HOME") + "RECENTS" -> global(GLOBAL_ACTION_RECENTS, "RECENTS") + "NOTIFICATIONS" -> global(GLOBAL_ACTION_NOTIFICATIONS, "NOTIFICATIONS") + "QUICK_SETTINGS" -> global(GLOBAL_ACTION_QUICK_SETTINGS, "QUICK_SETTINGS") + "POWER_DIALOG" -> global(GLOBAL_ACTION_POWER_DIALOG, "POWER_DIALOG") + "LOCK_SCREEN" -> global(GLOBAL_ACTION_LOCK_SCREEN, "LOCK_SCREEN") + "SCREENSHOT" -> global(GLOBAL_ACTION_TAKE_SCREENSHOT, "SCREENSHOT") + "SPLIT_SCREEN" -> global(GLOBAL_ACTION_TOGGLE_SPLIT_SCREEN, "SPLIT_SCREEN") + + // ---- touching the screen ------------------------------------------ + "CLICK" -> clickText(step.target.orEmpty()) + "CLICK_DESC" -> clickByDescription(step.target.orEmpty()) + "CLICK_ID" -> clickByViewId(step.target.orEmpty()) + "LONG_CLICK" -> longClickText(step.target.orEmpty()) + "INPUT" -> inputText(step.text.orEmpty()) + "CLEAR_INPUT" -> inputText("") + "SCROLL_FORWARD" -> scroll(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) + "SCROLL_BACKWARD" -> scroll(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) + "SWIPE_UP" -> swipe(0f, 0.7f, 0f, 0.3f) + "SWIPE_DOWN" -> swipe(0f, 0.3f, 0f, 0.7f) + "SWIPE_LEFT" -> swipe(0.8f, 0.5f, 0.2f, 0.5f) + "SWIPE_RIGHT" -> swipe(0.2f, 0.5f, 0.8f, 0.5f) + + // ---- timing -------------------------------------------------------- + "WAIT" -> { + delay((step.delayMs ?: 1_000).coerceIn(0, 10_000)) + null + } + + "WAIT_FOR" -> waitForText(step.target.orEmpty()) + + // ---- device -------------------------------------------------------- + "MEDIA_PLAY_PAUSE" -> media(android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) + "MEDIA_NEXT" -> media(android.view.KeyEvent.KEYCODE_MEDIA_NEXT) + "MEDIA_PREV" -> media(android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS) + "VOLUME_UP" -> volume(android.media.AudioManager.ADJUST_RAISE) + "VOLUME_DOWN" -> volume(android.media.AudioManager.ADJUST_LOWER) + "VOLUME_MUTE" -> volume(android.media.AudioManager.ADJUST_MUTE) + "TORCH_ON" -> torch(true) + "TORCH_OFF" -> torch(false) + "VIBRATE" -> { + runCatching { + getSystemService(android.os.VibratorManager::class.java).defaultVibrator + .vibrate(android.os.VibrationEffect.createOneShot(200, 180)) + }.exceptionOrNull()?.message + } + + "CLIPBOARD" -> { + runCatching { + getSystemService(android.content.ClipboardManager::class.java) + .setPrimaryClip(android.content.ClipData.newPlainText("LifeOS", step.text.orEmpty())) + }.exceptionOrNull()?.message + } + + "SHARE" -> shareText(step.text.orEmpty()) + "TOAST" -> { + android.widget.Toast.makeText(this, step.text.orEmpty(), android.widget.Toast.LENGTH_SHORT).show() + null + } + + // ---- LifeOS itself ------------------------------------------------- + "LIFEOS_TASK" -> dispatch( + LifeAction.CreateTask(step.text.orEmpty().take(100), SOURCE), + "task title", + step.text, + ) + + "LIFEOS_NOTE" -> { + val title = step.text.orEmpty().substringBefore('|').trim() + val body = step.text.orEmpty().substringAfter('|', "").trim() + dispatch(LifeAction.CreateNote(title.take(60), body.ifBlank { title }, SOURCE), "note title", title) + } + + "LIFEOS_TIMER" -> dispatch( + LifeAction.CreateReminder( + "Timer", + System.currentTimeMillis() + (step.delayMs ?: 300_000L), + SOURCE, + ), + "timer length", + step.delayMs?.toString(), + ) + + "LIFEOS_FOCUS" -> dispatch( + LifeAction.StartFocusTimer(((step.delayMs ?: 1_500_000L) / 60_000L).toInt().coerceAtLeast(1), SOURCE), + "focus length", + step.delayMs?.toString(), + ) + + "LIFEOS_BRICK_ON" -> dispatch( + LifeAction.StartBrickMode(step.target.orEmpty(), SOURCE), + "Brick mode name", + step.target, + ) + + "LIFEOS_BRICK_OFF" -> dispatch(LifeAction.StopBrickMode(SOURCE), "", "ok") + + else -> "Unsupported action ${step.action}" + } + + private fun global(action: Int, label: String): String? = + if (performGlobalAction(action)) null else "$label was refused by the system" + + private suspend fun dispatch(action: LifeAction, argName: String, argValue: String?): String? { + if (argName.isNotEmpty() && argValue.isNullOrBlank()) return "missing $argName" + val dispatcher = actionDispatcher ?: return "LifeOS action dispatcher is not available" + return when (val result = dispatcher.dispatch(action)) { + is LifeResult.Failure -> result.error.message + is LifeResult.Success -> null + } + } + + private fun openUri(uri: String): String? { + if (uri.isBlank()) return "no link given" + return runCatching { + startActivity( + Intent(Intent.ACTION_VIEW, android.net.Uri.parse(uri)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + null + }.getOrElse { "nothing can open \"$uri\"" } + } + + private fun openSettings(section: String): String? { + val action = when (section.trim().lowercase()) { + "wifi", "wi-fi" -> android.provider.Settings.ACTION_WIFI_SETTINGS + "bluetooth" -> android.provider.Settings.ACTION_BLUETOOTH_SETTINGS + "nfc" -> android.provider.Settings.ACTION_NFC_SETTINGS + "apps" -> android.provider.Settings.ACTION_APPLICATION_SETTINGS + "battery" -> android.provider.Settings.ACTION_BATTERY_SAVER_SETTINGS + "display", "brightness" -> android.provider.Settings.ACTION_DISPLAY_SETTINGS + "sound", "volume" -> android.provider.Settings.ACTION_SOUND_SETTINGS + "location" -> android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS + "dnd", "do not disturb" -> android.provider.Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS + "airplane" -> android.provider.Settings.ACTION_AIRPLANE_MODE_SETTINGS + "" -> android.provider.Settings.ACTION_SETTINGS + else -> android.provider.Settings.ACTION_SETTINGS + } + return runCatching { + startActivity(Intent(action).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + null + }.getOrElse { "that settings page is not available" } + } + + private fun shareText(text: String): String? = runCatching { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + } + startActivity(Intent.createChooser(send, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + null + }.getOrElse { "sharing failed" } + + private fun media(keyCode: Int): String? = runCatching { + val manager = getSystemService(android.media.AudioManager::class.java) + manager.dispatchMediaKeyEvent( + android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, keyCode), + ) + manager.dispatchMediaKeyEvent( + android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, keyCode), + ) + null + }.getOrElse { "no media session responded" } + + private fun volume(direction: Int): String? = runCatching { + getSystemService(android.media.AudioManager::class.java).adjustStreamVolume( + android.media.AudioManager.STREAM_MUSIC, + direction, + android.media.AudioManager.FLAG_SHOW_UI, + ) + null + }.getOrElse { "volume change was refused" } + + private fun torch(on: Boolean): String? = runCatching { + val manager = getSystemService(android.hardware.camera2.CameraManager::class.java) + val id = manager.cameraIdList.firstOrNull { cameraId -> + manager.getCameraCharacteristics(cameraId) + .get(android.hardware.camera2.CameraCharacteristics.FLASH_INFO_AVAILABLE) == true + } ?: return "this phone has no torch" + manager.setTorchMode(id, on) + null + }.getOrElse { "the torch is in use by another app" } + + private fun scroll(action: Int): String? { + val root = rootInActiveWindow ?: return "No active window" + val scrollable = findScrollable(root) ?: return "nothing scrollable on screen" + return if (scrollable.performAction(action)) null else "scroll was refused" + } + + private fun findScrollable(node: AccessibilityNodeInfo?): AccessibilityNodeInfo? { + if (node == null) return null + if (node.isScrollable) return node + for (index in 0 until node.childCount) { + findScrollable(node.getChild(index))?.let { return it } + } + return null + } + + /** Swipes using fractions of the screen, so it works on any display size. */ + private suspend fun swipe(fromX: Float, fromY: Float, toX: Float, toY: Float): String? { + val metrics = resources.displayMetrics + val width = metrics.widthPixels + val height = metrics.heightPixels + val path = android.graphics.Path().apply { + moveTo(if (fromX == 0f) width / 2f else width * fromX, height * fromY) + lineTo(if (toX == 0f) width / 2f else width * toX, height * toY) + } + val gesture = android.accessibilityservice.GestureDescription.Builder() + .addStroke(android.accessibilityservice.GestureDescription.StrokeDescription(path, 0, 250)) + .build() + return if (dispatchGesture(gesture, null, null)) { + delay(300) + null + } else { + "gesture was refused" + } + } + + private suspend fun waitForText(text: String): String? { + if (text.isBlank()) return "no text to wait for" + repeat(20) { + val root = rootInActiveWindow + if (root != null && !root.findAccessibilityNodeInfosByText(text).isNullOrEmpty()) return null + delay(500) + } + return "\"$text\" never appeared" + } + + private fun clickByDescription(description: String): String? { + val root = rootInActiveWindow ?: return "No active window" + val match = findByDescription(root, description) ?: return "nothing described as \"$description\"" + val target = match.clickableSelfOrAncestor() ?: return "\"$description\" is not tappable" + return if (target.performAction(AccessibilityNodeInfo.ACTION_CLICK)) null else "Tap failed" + } + + private fun findByDescription(node: AccessibilityNodeInfo?, description: String): AccessibilityNodeInfo? { + if (node == null) return null + if (node.contentDescription?.toString()?.contains(description, ignoreCase = true) == true) return node + for (index in 0 until node.childCount) { + findByDescription(node.getChild(index), description)?.let { return it } + } + return null + } + + private fun clickByViewId(viewId: String): String? { + val root = rootInActiveWindow ?: return "No active window" + val nodes = root.findAccessibilityNodeInfosByViewId(viewId) + if (nodes.isNullOrEmpty()) return "no view with id \"$viewId\"" + val target = nodes.firstNotNullOfOrNull { it.clickableSelfOrAncestor() } + ?: return "that view is not tappable" + return if (target.performAction(AccessibilityNodeInfo.ACTION_CLICK)) null else "Tap failed" + } + + private fun longClickText(text: String): String? { + val root = rootInActiveWindow ?: return "No active window" + val nodes = root.findAccessibilityNodeInfosByText(text) + if (nodes.isNullOrEmpty()) return "\"$text\" not on screen" + val target = nodes.firstNotNullOfOrNull { it.clickableSelfOrAncestor() } + ?: return "\"$text\" cannot be held" + return if (target.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK)) null else "Long press failed" + } + private fun launchApp(nameOrPackage: String): String? { + if (nameOrPackage.isBlank()) return "no app named" val pm = packageManager - val direct = pm.getLaunchIntentForPackage(nameOrPackage) + val cleaned = nameOrPackage.trim().replace(Regex("(?i)\\s+(app|application)$"), "").trim() + val direct = pm.getLaunchIntentForPackage(cleaned) val intent = direct ?: run { val installed = pm.getInstalledApplications(0) - val match = installed.firstOrNull { - pm.getApplicationLabel(it).toString().equals(nameOrPackage, ignoreCase = true) - } ?: installed.firstOrNull { - pm.getApplicationLabel(it).toString().contains(nameOrPackage, ignoreCase = true) - } + fun label(info: android.content.pm.ApplicationInfo) = pm.getApplicationLabel(info).toString() + val match = installed.firstOrNull { label(it).equals(cleaned, ignoreCase = true) } + ?: installed.firstOrNull { label(it).contains(cleaned, ignoreCase = true) } + ?: installed.firstOrNull { cleaned.contains(label(it), ignoreCase = true) } + ?: installed.firstOrNull { it.packageName.contains(cleaned.lowercase()) } match?.let { pm.getLaunchIntentForPackage(it.packageName) } } ?: return "No app matching \"$nameOrPackage\"" intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -126,6 +400,7 @@ class LifeAccessibilityService : AccessibilityService() { } private const val TAG = "LifeAccessibility" + private val SOURCE = SourceRef(LifeModule.AGENTIC, "macro") init { LifeLogger.d(TAG, "Macro engine class loaded") diff --git a/feature/brick/build.gradle.kts b/feature/brick/build.gradle.kts index 04373de..575884e 100644 --- a/feature/brick/build.gradle.kts +++ b/feature/brick/build.gradle.kts @@ -6,6 +6,7 @@ plugins { dependencies { implementation(projects.core.common) + implementation(projects.core.service) implementation(projects.core.designsystem) implementation(projects.core.database) implementation(projects.core.datastore) diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickJarvisBridge.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickJarvisBridge.kt new file mode 100644 index 0000000..07e4865 --- /dev/null +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickJarvisBridge.kt @@ -0,0 +1,119 @@ +package com.lifeos.feature.brick.data + +import com.lifeos.core.common.result.LifeError +import com.lifeos.core.common.result.LifeResult +import com.lifeos.core.database.brick.BrickDao +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 + +/** Brick as Jarvis reads it: which modes exist and what is blocking now. */ +internal class BrickProvider @Inject constructor( + private val brickDao: BrickDao, + private val repository: BrickRepository, +) : LifeDataProvider { + + override val topic: String = "brick" + override val description: String = "app-blocking modes, which one is running, unlocks left" + + override suspend fun read(query: String?): String { + val profiles = brickDao.allProfiles() + val active = repository.active.value + return buildString { + if (profiles.isEmpty()) { + appendLine("Brick: no modes set up yet.") + } else { + appendLine("Brick modes:") + profiles.forEach { profile -> + val apps = profile.blockedPackages.lines().count { it.isNotBlank() } + append("- ${profile.name}: $apps app(s)") + if (profile.inverse) { + append( + ", inverse ${BrickPolicy.formatMinute(profile.startMinuteOfDay)}" + + "-${BrickPolicy.formatMinute(profile.endMinuteOfDay)}" + + ", ${profile.unlockMinutes} min per tap", + ) + } else { + append(", on: ${profile.activator}, off: ${profile.deactivator}") + } + if (profile.strict) append(", strict") + appendLine() + } + } + if (active == null) { + appendLine("Nothing is blocking right now.") + } else { + appendLine("Running: \"${active.profile.name}\", ${active.blockedPackages.size} app(s) blocked.") + if (active.profile.inverse) { + val left = BrickPolicy.unlocksLeft(active.rules) + val remaining = repository.unlockRemaining() + appendLine( + if (remaining != null) { + "Access is open for another ${remaining / 60_000} min." + } else { + "Blocked now; ${left ?: "unlimited"} unlock(s) left in this window." + }, + ) + } + } + }.trim() + } +} + +/** Starting and stopping Brick modes on Jarvis's word. */ +internal class BrickActionHandler @Inject constructor( + private val brickDao: BrickDao, + private val repository: BrickRepository, +) : LifeActionHandler { + + override fun canHandle(action: LifeAction): Boolean = + action is LifeAction.StartBrickMode || action is LifeAction.StopBrickMode + + override suspend fun execute(action: LifeAction): LifeResult = when (action) { + is LifeAction.StartBrickMode -> { + repository.refresh() + val needle = action.modeName.trim().lowercase() + val profiles = brickDao.allProfiles() + val profile = profiles.firstOrNull { it.name.lowercase() == needle } + ?: profiles.firstOrNull { needle in it.name.lowercase() } + when { + profile == null -> + LifeResult.Failure(LifeError.Validation("No Brick mode called \"${action.modeName}\"")) + + repository.start(profile.id, "MANUAL") -> LifeResult.Success(profile.id) + else -> LifeResult.Failure(LifeError.Validation("Another mode is already running")) + } + } + + is LifeAction.StopBrickMode -> { + repository.refresh() + if (repository.stop("MANUAL")) { + LifeResult.Success(null) + } else { + // Strict and inverse modes refuse this on purpose. + LifeResult.Failure(LifeError.Validation("That mode will not end early")) + } + } + + else -> LifeResult.Failure(LifeError.Validation("Unsupported action")) + } +} + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class BrickJarvisModule { + + @Binds + @IntoSet + abstract fun bindProvider(impl: BrickProvider): LifeDataProvider + + @Binds + @IntoSet + abstract fun bindHandler(impl: BrickActionHandler): LifeActionHandler +} diff --git a/feature/chat/src/main/kotlin/com/lifeos/feature/chat/ChatScreen.kt b/feature/chat/src/main/kotlin/com/lifeos/feature/chat/ChatScreen.kt index 22a8dab..42b50c5 100644 --- a/feature/chat/src/main/kotlin/com/lifeos/feature/chat/ChatScreen.kt +++ b/feature/chat/src/main/kotlin/com/lifeos/feature/chat/ChatScreen.kt @@ -1,34 +1,37 @@ package com.lifeos.feature.chat +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.draw.clip -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AssistChip import androidx.compose.material3.ExperimentalMaterial3Api @@ -46,9 +49,15 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -81,6 +90,15 @@ internal fun ChatScreen( } } + // Gallery images are copied into app storage so the model can read them + // after the picker's temporary permission is gone. + val context = LocalContext.current + val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) { + copyToChatImages(context, uri)?.let { path -> onEvent(ChatUiEvent.ImageAttached(path)) } + } + } + Scaffold( topBar = { TopAppBar( @@ -133,11 +151,39 @@ internal fun ChatScreen( if (uiState.debugEnabled) { JarvisDebugPanel(uiState.debugLog) } + // Attached images sit above the bar so it stays obvious what will be sent. + if (uiState.pendingImages.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + uiState.pendingImages.forEach { path -> + Box { + ThumbImage(path = path, size = 72.dp, description = "Attached image") + IconButton( + onClick = { onEvent(ChatUiEvent.ImageRemoved(path)) }, + modifier = Modifier.align(Alignment.TopEnd).size(24.dp), + ) { + Icon( + Icons.Filled.Close, + contentDescription = "Remove image", + modifier = Modifier.size(16.dp), + ) + } + } + } + } + } AiInputBar( value = uiState.input, onValueChange = { onEvent(ChatUiEvent.InputChanged(it)) }, onSend = { onEvent(ChatUiEvent.Send) }, busy = uiState.streaming, + onAttachImage = { imagePicker.launch("image/*") }, + hasAttachments = uiState.pendingImages.isNotEmpty(), modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), ) } @@ -346,6 +392,19 @@ private fun MessageBubble(message: AiMessageEntity) { .padding(horizontal = 14.dp, vertical = 10.dp), ) { Column(horizontalAlignment = Alignment.Start) { + val images = remember(message.imagePaths) { + message.imagePaths.split('\n').map { it.trim() }.filter { it.isNotEmpty() } + } + if (images.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(bottom = 8.dp), + ) { + images.forEach { path -> + ThumbImage(path = path, size = 140.dp) + } + } + } val (thoughts, answer) = remember(message.content) { splitThinking(message.content) } if (thoughts != null) { ThoughtChain(thoughts) @@ -417,3 +476,41 @@ private fun splitThinking(content: String): Pair { afterOpen.substring(0, close) to afterOpen.substring(close + 8).trim() } } + +/** + * Copies a picked image into the app's chat-images folder and returns its path. + * The picker only grants a short-lived permission on the Uri, and inference runs + * later on another thread, so the bytes have to be ours first. + */ +private fun copyToChatImages(context: android.content.Context, uri: android.net.Uri): String? = runCatching { + val dir = java.io.File(context.filesDir, "chat-images").apply { mkdirs() } + val target = java.io.File(dir, "img-${System.currentTimeMillis()}.jpg") + context.contentResolver.openInputStream(uri)?.use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } ?: return null + target.absolutePath +}.getOrNull() + +/** Bounded bitmap thumbnail, decoded once per path (no image library needed). */ +@Composable +private fun ThumbImage(path: String, size: androidx.compose.ui.unit.Dp, description: String? = null) { + val bitmap = remember(path) { + runCatching { + val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } + android.graphics.BitmapFactory.decodeFile(path, bounds) + val longest = maxOf(bounds.outWidth, bounds.outHeight).coerceAtLeast(1) + val options = android.graphics.BitmapFactory.Options().apply { + inSampleSize = 1 + while (longest / inSampleSize > 512) inSampleSize *= 2 + } + android.graphics.BitmapFactory.decodeFile(path, options)?.asImageBitmap() + }.getOrNull() + } + if (bitmap == null) return + Image( + bitmap = bitmap, + contentDescription = description, + contentScale = ContentScale.Crop, + modifier = Modifier.size(size).clip(RoundedCornerShape(12.dp)), + ) +} diff --git a/feature/chat/src/main/kotlin/com/lifeos/feature/chat/ChatViewModel.kt b/feature/chat/src/main/kotlin/com/lifeos/feature/chat/ChatViewModel.kt index d20bc96..1a3bf4b 100644 --- a/feature/chat/src/main/kotlin/com/lifeos/feature/chat/ChatViewModel.kt +++ b/feature/chat/src/main/kotlin/com/lifeos/feature/chat/ChatViewModel.kt @@ -32,6 +32,8 @@ data class ChatUiState( val showSettings: Boolean = false, /** Manual context (notes, pasted files) attached to every prompt. */ val contextText: String = "", + /** Images attached to the next message (vision models read them). */ + val pendingImages: List = emptyList(), val showContext: Boolean = false, /** Developer Options: show Jarvis internals + a copy-debug button. */ val debugEnabled: Boolean = false, @@ -50,6 +52,9 @@ sealed interface ChatUiEvent { data class ContextChanged(val value: String) : ChatUiEvent /** File picked from the document picker — appended to the context. */ data class ContextFileAttached(val name: String, val content: String) : ChatUiEvent + /** Image picked from the gallery, already copied into app storage. */ + data class ImageAttached(val path: String) : ChatUiEvent + data class ImageRemoved(val path: String) : ChatUiEvent data object DismissError : ChatUiEvent } @@ -110,6 +115,12 @@ class ChatViewModel @Inject constructor( }, ) } + is ChatUiEvent.ImageAttached -> updateState { + it.copy(pendingImages = (it.pendingImages + event.path).takeLast(2)) + } + is ChatUiEvent.ImageRemoved -> updateState { + it.copy(pendingImages = it.pendingImages - event.path) + } ChatUiEvent.DismissError -> updateState { it.copy(error = null) } } } @@ -127,7 +138,9 @@ class ChatViewModel @Inject constructor( private fun send() { val typed = uiState.value.input.trim() - if (typed.isEmpty() || uiState.value.streaming) return + val images = uiState.value.pendingImages + // An image on its own is a valid question ("what is this?"). + if ((typed.isEmpty() && images.isEmpty()) || uiState.value.streaming) return // Manual context rides along visibly — no hidden prompt surgery. val context = uiState.value.contextText.trim() @@ -137,9 +150,13 @@ class ChatViewModel @Inject constructor( "[Context]\n$context\n[/Context]\n\n$typed" } - updateState { it.copy(input = "", streaming = true, error = null) } + updateState { it.copy(input = "", streaming = true, error = null, pendingImages = emptyList()) } sendJob = viewModelScope.launch { - chatRepository.sendMessage(uiState.value.activeConversationId, text) + chatRepository.sendMessage( + conversationId = uiState.value.activeConversationId, + text = text.ifBlank { "What is in this image?" }, + imagePaths = images, + ) .collect { progress -> when (progress) { is ReplyProgress.Started -> { 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 39a146d..d58ff21 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 @@ -31,7 +31,11 @@ interface ChatRepository { * is null), streams the assistant reply into a message row, and reports * progress. History is replayed to the engine for context. */ - fun sendMessage(conversationId: Long?, text: String): Flow + fun sendMessage( + conversationId: Long?, + text: String, + imagePaths: List = emptyList(), + ): Flow suspend fun deleteConversation(conversationId: Long) } @@ -51,7 +55,11 @@ internal class DefaultChatRepository @Inject constructor( override fun observeMessages(conversationId: Long): Flow> = chatDao.observeMessages(conversationId) - override fun sendMessage(conversationId: Long?, text: String): Flow = flow { + override fun sendMessage( + conversationId: Long?, + text: String, + imagePaths: List, + ): Flow = flow { val now = System.currentTimeMillis() val convId = conversationId ?: chatDao.insertConversation( AiConversationEntity(title = text.take(48), createdAt = now, updatedAt = now), @@ -70,9 +78,10 @@ internal class DefaultChatRepository @Inject constructor( content = text, engine = null, createdAt = now, + imagePaths = imagePaths.joinToString("\n"), ), ) - history += AiMessage(AiRole.USER, text) + history += AiMessage(AiRole.USER, text, imagePaths = imagePaths) var assistantMessageId: Long? = null var engine: AiEngineId? = null @@ -103,34 +112,59 @@ internal class DefaultChatRepository @Inject constructor( // maxTokens budget covers input AND output — an oversized prompt eats // the answer's budget and truncates it mid-sentence. Keep it tight. debug.beginTurn(text) + // Trimming keeps the newest turn's images: they are the question. val trimmedHistory = history.takeLast(4).map { it.copy(content = it.content.take(400)) } val snapshot = runCatching { toolbox.snapshot() }.getOrDefault("") val system = SYSTEM_PROMPT + "\n\n" + toolbox.toolSpec + "\n\n" + snapshot debug.add("snapshot", snapshot) - val request = AiRequest(messages = trimmedHistory, system = system) - aiRouter.stream(request).collect { event -> - when (event) { - is AiRouter.StreamEvent.EngineSelected -> { - engine = event.engine - emit(ReplyProgress.Started(convId, event.engine)) - } - is AiRouter.StreamEvent.Restart -> { - engine = event.engine - accumulated.setLength(0) - emit(ReplyProgress.Started(convId, event.engine)) - } - is AiRouter.StreamEvent.Chunk -> { - accumulated.append(event.chunk.text) - persistAssistant() - emit(ReplyProgress.Delta(accumulated.toString())) - } - is AiRouter.StreamEvent.Failed -> { - debug.add("error", event.error.message) - emit(ReplyProgress.Failed(event.error)) + var request = AiRequest(messages = trimmedHistory, system = system) + + // Two-pass tool use: the first reply may ask for a module's detail with + // a [[get: topic]] line. That costs one extra inference only when the + // model actually needs data, which is why the always-on snapshot can + // stay small. + suspend fun runPass() { + aiRouter.stream(request).collect { event -> + when (event) { + is AiRouter.StreamEvent.EngineSelected -> { + engine = event.engine + emit(ReplyProgress.Started(convId, event.engine)) + } + is AiRouter.StreamEvent.Restart -> { + engine = event.engine + accumulated.setLength(0) + emit(ReplyProgress.Started(convId, event.engine)) + } + is AiRouter.StreamEvent.Chunk -> { + accumulated.append(event.chunk.text) + persistAssistant() + emit(ReplyProgress.Delta(accumulated.toString())) + } + is AiRouter.StreamEvent.Failed -> { + debug.add("error", event.error.message) + emit(ReplyProgress.Failed(event.error)) + } } } } + runPass() + + val reads = toolbox.requestedReads(accumulated.toString()) + if (reads.isNotEmpty()) { + 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 (accumulated.isNotEmpty()) { debug.add("raw-output", accumulated.toString()) // Execute any [[tool: args]] lines the model emitted; the final 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 9113ee9..1e0b73d 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 @@ -13,6 +13,9 @@ import com.lifeos.core.common.storage.LifeOsPublicMirror import com.lifeos.core.model.LifeModule import com.lifeos.core.model.SourceRef import com.lifeos.core.service.LifeAction +import com.lifeos.core.common.result.LifeResult +import com.lifeos.core.service.ActionEcho +import com.lifeos.core.service.LifeDataProvider import com.lifeos.core.service.LifeActionDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first @@ -45,22 +48,102 @@ class JarvisToolbox @Inject constructor( private val financeDao: FinanceDao, private val bookDao: BookDao, private val publicMirror: LifeOsPublicMirror, + /** + * Every module's own read-side, registered from the feature modules. Kept + * out of the always-on prompt on purpose: detail is fetched only when the + * model asks for it, so a chat that needs nothing costs nothing. + */ + private val providers: Set<@JvmSuppressWildcards LifeDataProvider>, + private val echo: ActionEcho, ) { - /** The tool contract the model sees. Kept terse — every char costs latency. */ - val toolSpec: String = - """ - To CHANGE the user's LifeOS data, emit commands on their own line, exactly: + /** + * The tool contract the model sees. Kept terse — every char costs latency, + * and the topic list is generated from whatever modules are installed. + */ + val toolSpec: String + get() = """ + To CHANGE LifeOS data, emit commands on their own line, exactly: [[add_task: title]] [[done_task: id]] [[delete_task: id]] [[timer: 5m]] [[remind: 18:00 | title]] [[remind: +25m | title]] [[cancel_reminder: id]] [[event: tomorrow 15:00 | title]] [[note: title | body]] [[edit_note: title | new full body]] [[append_note: title | text to add]] - Rules: Commands are ONLY for changing LifeOS data — for everything else (questions, - math, writing, chat) just answer normally in plain words using LIVE DATA when it's - about the user's own items. The data is already in front of you: never say you will - look or search. Use ids from LIVE DATA. Never claim an action you didn't emit. + [[paste: title | text]] [[burner_paste: title | text]] (burner = one-time, encrypted) + [[download: url]] [[water_plant: name]] [[add_plant: name | species | every N days]] + [[brick_on: mode name]] [[brick_off:]] [[focus: 25m]] [[focus_stop:]] + [[sync_screen_time:]] [[export_screen_time: json|csv_days|csv_apps]] + [[run_macro: name]] + 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 + below. For anything else (questions, math, writing, chat) just answer in plain words. + Never say you will look something up: either emit [[get:]] or answer. Use ids from + LIVE DATA. Never claim an action you did not emit. """.trimIndent() + private fun topicList(): String = + (BUILT_IN_TOPICS + providers.map { it.topic }).distinct().sorted().joinToString(", ") + + /** True when the reply asks for data, meaning the turn needs a second pass. */ + fun requestedReads(modelText: String): List> = + TOOL_TAG.findAll(repair(modelText)) + .filter { it.groupValues[1].trim().lowercase() == "get" } + .map { match -> + val (topic, query) = splitArgs(match.groupValues[2]) + topic.trim().lowercase().replace(' ', '_') to query.takeIf { it.isNotBlank() } + } + .toList() + + /** Runs the requested reads and returns a block to feed back to the model. */ + suspend fun fetchReads(reads: List>, debug: JarvisDebug? = null): String { + val blocks = mutableListOf() + reads.take(MAX_READS_PER_TURN).forEach { (topic, query) -> + val body = runCatching { readTopic(topic, query) } + .getOrElse { "$topic: could not be read (${it.message})" } + debug?.add("read", "$topic(${query ?: ""}) -> ${body.take(200)}") + blocks += body.take(1200) + } + return blocks.joinToString("\n\n") + } + + private suspend fun readTopic(topic: String, query: String?): String { + providers.firstOrNull { it.topic == topic }?.let { return it.read(query) } + return when (topic) { + "tasks" -> captureDao.observeTasks().first().filter { !it.done } + .joinToString("\n") { "- [${it.id}] ${it.title}" } + .ifBlank { "No open tasks." } + + "notes" -> noteDao.observeAll().first() + .joinToString("\n") { "- ${it.title}" } + .ifBlank { "No notes." } + + "calendar" -> calendarDao.observeUpcoming(System.currentTimeMillis()).first() + .take(15) + .joinToString("\n") { "- ${AT.format(Date(it.startsAt))} ${it.title}" } + .ifBlank { "Calendar is empty." } + + "reminders" -> reminderDao.observeAll().first().filter { it.enabled && it.firedAt == null } + .joinToString("\n") { "- [${it.id}] ${AT.format(Date(it.at))} ${it.title}" } + .ifBlank { "No reminders." } + + "packages" -> packageDao.activePackages() + .joinToString("\n") { "- ${it.label ?: it.trackingNumber}: ${it.statusDescription ?: it.status}" } + .ifBlank { "No tracked parcels." } + + "finance" -> financeDao.observeSubscriptions().first() + .joinToString("\n") { "- ${it.merchant} ${it.amountCents / 100.0}€ ${it.cadence}" } + .ifBlank { "No subscriptions." } + + "books" -> bookDao.observeAll().first() + .joinToString("\n") { "- ${it.title} — ${it.author} (${it.status})" } + .ifBlank { "No books shelved." } + + "search" -> search(query.orEmpty()) + + else -> "Unknown topic \"$topic\". Known: ${topicList()}" + } + } + /** Live cross-module state, compact, with stable ids the model can act on. */ suspend fun snapshot(): String = buildString { val now = System.currentTimeMillis() @@ -140,10 +223,7 @@ class JarvisToolbox @Inject constructor( val results = mutableListOf() // Small models often drop one closing bracket ("…body.]"). Repair // lines that open a tool tag but only close with a single ']'. - val repaired = modelText.lineSequence().joinToString("\n") { line -> - val t = line.trimEnd() - if (t.startsWith("[[") && !t.endsWith("]]") && t.endsWith("]")) "$t]" else line - } + val repaired = repair(modelText) TOOL_TAG.findAll(repaired).forEach { match -> val tool = match.groupValues[1].trim().lowercase() val args = match.groupValues[2].trim() @@ -169,6 +249,12 @@ class JarvisToolbox @Inject constructor( } } + /** Small models often drop one closing bracket ("…body.]"). */ + private fun repair(modelText: String): String = modelText.lineSequence().joinToString("\n") { line -> + val t = line.trimEnd() + if (t.startsWith("[[") && !t.endsWith("]]") && t.endsWith("]")) "$t]" else line + } + private suspend fun execute(tool: String, args: String): String? = when (tool) { "add_task" -> { val title = args.trim().trim('"').take(80) @@ -214,9 +300,105 @@ class JarvisToolbox @Inject constructor( "edit_note" -> rewriteNote(args, append = false) "append_note" -> rewriteNote(args, append = true) "search" -> search(args) + + // ---- module actions (dispatched, so chat never imports a feature) ---- + "paste", "burner_paste" -> { + val (title, body) = splitArgs(args) + val content = body.ifBlank { title } + if (content.isBlank()) error("nothing to paste") + dispatch( + LifeAction.CreatePaste( + title = if (body.isBlank()) "From Jarvis" else title, + content = content, + burner = tool == "burner_paste", + password = "", + source = SOURCE, + ), + ) + val url = echo.lastUrl + if (tool == "burner_paste") { + "One-time encrypted paste: ${url ?: "created"}" + } else { + "Paste created: ${url ?: "done"}" + } + } + + "download" -> { + dispatch(LifeAction.StartDownload(args.trim(), SOURCE)) + "Download queued from ${args.trim().take(60)}" + } + + "water_plant" -> { + dispatch(LifeAction.WaterPlant(args.trim(), SOURCE)) + "Watered ${args.trim()}" + } + + "add_plant" -> { + val parts = args.split('|').map { it.trim() } + val name = parts.firstOrNull().orEmpty() + if (name.isBlank()) error("need a plant name") + val days = parts.getOrNull(2)?.filter { it.isDigit() }?.toIntOrNull() ?: 7 + dispatch( + LifeAction.AddPlant( + plantName = name, + species = parts.getOrNull(1).orEmpty(), + waterEveryDays = days, + source = SOURCE, + ), + ) + "Added plant $name, watering every ${days}d" + } + + "brick_on" -> { + dispatch(LifeAction.StartBrickMode(args.trim(), SOURCE)) + "Brick mode \"${args.trim()}\" is blocking" + } + + "brick_off" -> { + dispatch(LifeAction.StopBrickMode(SOURCE)) + "Brick mode ended" + } + + "focus" -> { + val minutes = (parseDuration(args) ?: error("bad duration \"$args\"")) / 60_000L + dispatch(LifeAction.StartFocusTimer(minutes.toInt().coerceAtLeast(1), SOURCE)) + "Focus timer started for ${minutes.toInt()} min" + } + + "focus_stop" -> { + dispatch(LifeAction.StopFocusTimer(SOURCE)) + "Focus timer stopped" + } + + "sync_screen_time" -> { + dispatch(LifeAction.SyncScreenTime(SOURCE)) + "Screen time synced" + } + + "export_screen_time" -> { + val weekOnly = args.contains("week", ignoreCase = true) + dispatch(LifeAction.ExportScreenTime(args.ifBlank { "json" }, weekOnly, SOURCE)) + "Screen-time export saved to Downloads: ${echo.lastFileName ?: "done"}" + } + + "run_macro" -> { + dispatch(LifeAction.RunMacro(args.trim(), SOURCE)) + "Ran macro \"${args.trim()}\"" + } + + // Reads are handled before this point (they feed a second pass). + "get" -> null else -> null } + /** Dispatches and turns a failure into an error the caller reports. */ + private suspend fun dispatch(action: LifeAction) { + when (val result = dispatcher.dispatch(action)) { + is LifeResult.Failure -> error(result.error.message) + is LifeResult.Success -> Unit + } + } + /** Rewrites (or appends to) a plain note's file by fuzzy title match. */ private suspend fun rewriteNote(args: String, append: Boolean): String { val (titleQuery, newBody) = splitArgs(args) @@ -336,5 +518,10 @@ class JarvisToolbox @Inject constructor( val AT = SimpleDateFormat("EEE HH:mm", Locale.getDefault()) val STAMP = SimpleDateFormat("EEE d MMM HH:mm", Locale.getDefault()) val SEARCH_STOP = setOf("the", "and", "for", "with", "search", "find", "look", "please") + /** Topics served straight from core DAOs, without a feature provider. */ + val BUILT_IN_TOPICS = listOf( + "tasks", "notes", "calendar", "reminders", "packages", "finance", "books", "search", + ) + const val MAX_READS_PER_TURN = 2 } } diff --git a/feature/clearsky/build.gradle.kts b/feature/clearsky/build.gradle.kts index a0b65cf..f45a281 100644 --- a/feature/clearsky/build.gradle.kts +++ b/feature/clearsky/build.gradle.kts @@ -6,6 +6,7 @@ plugins { dependencies { implementation(projects.core.common) + implementation(projects.core.service) implementation(projects.core.designsystem) implementation(projects.core.datastore) implementation(projects.core.ui) diff --git a/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/data/ClearSkyJarvisBridge.kt b/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/data/ClearSkyJarvisBridge.kt new file mode 100644 index 0000000..5312dbf --- /dev/null +++ b/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/data/ClearSkyJarvisBridge.kt @@ -0,0 +1,67 @@ +package com.lifeos.feature.clearsky.data + +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 + +/** + * Stargazing conditions as Jarvis reads them (§Module Clear Sky Map). The query + * is a place name or "lat, lon"; with no query the saved spot is used. + */ +internal class ClearSkyProvider @Inject constructor( + private val repository: ClearSkyRepository, +) : LifeDataProvider { + + override val topic: String = "sky" + override val description: String = "stargazing forecast for a place (cloud, dark hours, moon)" + + override suspend fun read(query: String?): String { + val place = resolve(query) ?: return "Clear Sky: no spot saved yet, and \"$query\" matched nothing." + val forecast = repository.forecast(place, view = null).getOrElse { + return "Clear Sky: could not load the forecast (${it.message})." + } + val day = forecast.days.firstOrNull() ?: return "Clear Sky: no forecast rows for ${forecast.locationName}." + val good = day.hours.count { it.rating == SkyRating.GOOD } + val ok = day.hours.count { it.rating == SkyRating.OK } + val clouds = day.rows.firstOrNull { it.label.startsWith("Total Clouds") } + return buildString { + appendLine("Clear Sky for ${forecast.locationName}:") + appendLine("- tonight: $good good hour(s), $ok OK, ${day.hours.size - good - ok} bad") + appendLine("- astro dark ${day.astroDark.ifBlank { "none" }}, moon ${day.moonPhase} ${day.moonIllumination}") + appendLine("- sun sets ${day.sunSet}, rises ${day.sunRise}") + if (forecast.skyQualityMagnitude.isNotBlank()) { + appendLine("- sky quality ${forecast.skyQualityMagnitude} mag, Bortle ${forecast.bortleClass}") + } + clouds?.let { row -> + appendLine("- cloud cover next hours: " + row.values.take(8).joinToString(" ") { "$it%" }) + } + }.trim() + } + + private suspend fun resolve(query: String?): SkyPlace? { + if (query.isNullOrBlank()) return repository.lastPlace() ?: repository.places().firstOrNull() + val coordinates = Regex("""(-?\d+(?:\.\d+)?)\s*[,; ]\s*(-?\d+(?:\.\d+)?)""").find(query) + if (coordinates != null) { + val lat = coordinates.groupValues[1].toDoubleOrNull() + val lon = coordinates.groupValues[2].toDoubleOrNull() + if (lat != null && lon != null) return SkyPlace(query.trim(), lat, lon) + } + val saved = repository.places().firstOrNull { it.name.contains(query, ignoreCase = true) } + if (saved != null) return saved + return repository.search(query).getOrNull()?.firstOrNull() + ?: repository.lastPlace() + } +} + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class ClearSkyJarvisModule { + + @Binds + @IntoSet + abstract fun bindProvider(impl: ClearSkyProvider): LifeDataProvider +} diff --git a/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockScreen.kt b/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockScreen.kt index 761710a..1697028 100644 --- a/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockScreen.kt +++ b/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockScreen.kt @@ -1,6 +1,5 @@ package com.lifeos.feature.clock -import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures @@ -151,7 +150,7 @@ private fun FacesTab(face: Int, onFace: (Int) -> Unit) { FilterChip(selected = face == index, onClick = { onFace(index) }, label = { Text(label) }) } } - AnimatedContent(targetState = face, label = "face") { selected -> + FadeThrough(targetState = face, label = "face") { selected -> Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { when (selected) { 0 -> Text( @@ -548,7 +547,6 @@ private fun TimerTab() { WheelPicker(range = 0..59, value = seconds, onValue = { seconds = it }, onTap = { typedField = 2 }) WheelLabel("s") } - TextButton(onClick = { typedField = 0 }) { Text("Type a duration") } } } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/feature/downloader/build.gradle.kts b/feature/downloader/build.gradle.kts index 8c9f0fc..8b9fbe8 100644 --- a/feature/downloader/build.gradle.kts +++ b/feature/downloader/build.gradle.kts @@ -6,6 +6,7 @@ plugins { dependencies { implementation(projects.core.common) + implementation(projects.core.service) implementation(projects.core.designsystem) implementation(projects.core.database) implementation(projects.core.ui) diff --git a/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/data/DownloaderJarvisBridge.kt b/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/data/DownloaderJarvisBridge.kt new file mode 100644 index 0000000..4926a13 --- /dev/null +++ b/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/data/DownloaderJarvisBridge.kt @@ -0,0 +1,85 @@ +package com.lifeos.feature.downloader.data + +import com.lifeos.core.common.result.LifeError +import com.lifeos.core.common.result.LifeResult +import com.lifeos.core.database.downloads.DownloadDao +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 javax.inject.Inject + +/** Downloads as Jarvis reads them: queue, progress, what landed. */ +internal class DownloaderProvider @Inject constructor( + private val downloadDao: DownloadDao, +) : LifeDataProvider { + + override val topic: String = "downloads" + override val description: String = "download queue and finished files, plus supported sites" + + override suspend fun read(query: String?): String { + if (query != null && query.isNotBlank()) { + val matches = SiteCatalog.search(query).take(8) + if (matches.isNotEmpty()) { + return "Supported sites matching \"$query\": " + + matches.joinToString("; ") { "${it.name} (${it.host})" } + } + } + val rows = downloadDao.observeAll().first() + if (rows.isEmpty()) { + return "Downloads: nothing yet. ${SiteCatalog.sites.size} sites are supported." + } + return buildString { + appendLine("Downloads (${rows.size}):") + rows.take(8).forEach { row -> + val state = when (row.status) { + "DONE" -> "done, ${row.sizeBytes / 1_048_576} MB" + "FAILED" -> "failed: ${row.error ?: "unknown"}" + else -> "${row.status.lowercase()} ${row.progressPercent}%" + } + appendLine("- ${row.title.take(50)}: $state") + } + }.trim() + } +} + +/** Queues a download for a pasted page or file URL. */ +internal class DownloaderActionHandler @Inject constructor( + private val resolver: MediaResolver, + private val engine: DownloadEngine, +) : LifeActionHandler { + + override fun canHandle(action: LifeAction): Boolean = action is LifeAction.StartDownload + + override suspend fun execute(action: LifeAction): LifeResult { + val url = (action as LifeAction.StartDownload).url.trim() + if (!url.startsWith("http")) { + return LifeResult.Failure(LifeError.Validation("That is not a full http(s) URL")) + } + val outcome = runCatching { resolver.resolve(url) }.getOrElse { + return LifeResult.Failure(LifeError.Unknown(it.message ?: "Could not reach that URL")) + } + val best = outcome.candidates.firstOrNull() + ?: return LifeResult.Failure(LifeError.Validation("No downloadable media on that page")) + engine.enqueue(best, url) + return LifeResult.Success(null) + } +} + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class DownloaderJarvisModule { + + @Binds + @IntoSet + abstract fun bindProvider(impl: DownloaderProvider): LifeDataProvider + + @Binds + @IntoSet + abstract fun bindHandler(impl: DownloaderActionHandler): LifeActionHandler +} diff --git a/feature/news/build.gradle.kts b/feature/news/build.gradle.kts index 2c33413..7770e73 100644 --- a/feature/news/build.gradle.kts +++ b/feature/news/build.gradle.kts @@ -6,6 +6,7 @@ plugins { dependencies { implementation(projects.core.common) + implementation(projects.core.service) implementation(projects.core.designsystem) implementation(projects.core.ui) diff --git a/feature/news/src/main/kotlin/com/lifeos/feature/news/data/NewsJarvisBridge.kt b/feature/news/src/main/kotlin/com/lifeos/feature/news/data/NewsJarvisBridge.kt new file mode 100644 index 0000000..9b04f83 --- /dev/null +++ b/feature/news/src/main/kotlin/com/lifeos/feature/news/data/NewsJarvisBridge.kt @@ -0,0 +1,42 @@ +package com.lifeos.feature.news.data + +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 + +/** Headlines as Jarvis reads them (§Module News): live RSS, no cache. */ +internal class NewsProvider @Inject constructor( + private val repository: NewsRepository, +) : LifeDataProvider { + + override val topic: String = "news" + override val description: String = "current headlines from the configured feeds" + + override suspend fun read(query: String?): String { + val articles = runCatching { repository.latest(repository.sources.map { it.id }.toSet()) } + .getOrElse { return "News: could not reach the feeds (${it.message})." } + val filtered = if (query.isNullOrBlank()) { + articles + } else { + articles.filter { it.title.contains(query, ignoreCase = true) } + } + if (filtered.isEmpty()) return "News: nothing matching right now." + return buildString { + appendLine("Headlines:") + filtered.take(10).forEach { appendLine("- [${it.source}] ${it.title.take(110)}") } + }.trim() + } +} + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class NewsJarvisModule { + + @Binds + @IntoSet + abstract fun bindProvider(impl: NewsProvider): LifeDataProvider +} diff --git a/feature/pastebin/build.gradle.kts b/feature/pastebin/build.gradle.kts index 2c0c8fd..1003adf 100644 --- a/feature/pastebin/build.gradle.kts +++ b/feature/pastebin/build.gradle.kts @@ -6,6 +6,7 @@ plugins { dependencies { implementation(projects.core.common) + implementation(projects.core.service) implementation(projects.core.designsystem) implementation(projects.core.database) implementation(projects.core.datastore) diff --git a/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/PastebinScreen.kt b/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/PastebinScreen.kt index 6c3e5ef..cd84b28 100644 --- a/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/PastebinScreen.kt +++ b/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/PastebinScreen.kt @@ -175,8 +175,7 @@ private fun ComposerTab(state: PastebinUiState, viewModel: PastebinViewModel) { Column(modifier = Modifier.padding(start = 8.dp)) { Text("Burn after read") Text( - "Deleted the moment it is opened once. Pastebin only allows this on guest pastes, " + - "so a burner paste is posted outside your account.", + "Deleted the moment it is opened once.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -192,6 +191,34 @@ private fun ComposerTab(state: PastebinUiState, viewModel: PastebinViewModel) { modifier = Modifier.fillMaxWidth(), ) } + item { + // Pastebin's API has neither burn-after-read nor passwords, so those + // requests go to PrivateBin. Say which one will be used, up front. + val burner = state.burnAfterRead || state.password.isNotBlank() + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + if (burner) "Goes to PrivateBin" else "Goes to Pastebin", + style = MaterialTheme.typography.labelLarge, + ) + Text( + if (burner) { + "Burn-after-read and passwords do not exist in Pastebin's API, so this one is " + + "created on PrivateBin instead: encrypted on this phone, the key travels in " + + "the link, and the server only ever holds ciphertext." + } else { + if (state.signedIn) { + "Posted under your account, so it shows up in My pastes." + } else { + "Posted as a guest. Sign in on the My pastes tab to keep them in your account." + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } item { if (state.posting) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) Button( @@ -369,6 +396,24 @@ private fun SettingsTab(state: PastebinUiState, viewModel: PastebinViewModel) { modifier = Modifier.fillMaxWidth(), ) } + item { Text("Burner backend", style = MaterialTheme.typography.labelLarge) } + item { + OutlinedTextField( + value = state.privateBinInstance, + onValueChange = viewModel::onPrivateBinInstance, + label = { Text("PrivateBin instance") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + item { + Text( + "Burn-after-read and password-protected pastes are created here instead of on Pastebin, " + + "which has neither in its API. Any PrivateBin instance works - your own included.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } diff --git a/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/PastebinViewModel.kt b/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/PastebinViewModel.kt index 1a34a09..10747fd 100644 --- a/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/PastebinViewModel.kt +++ b/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/PastebinViewModel.kt @@ -34,6 +34,7 @@ data class PastebinUiState( val loadingList: Boolean = false, // Share defaults val shareDefaults: ShareDefaults = ShareDefaults(), + val privateBinInstance: String = "", val message: String? = null, ) @@ -50,6 +51,7 @@ class PastebinViewModel @Inject constructor( _uiState.value = _uiState.value.copy( shareDefaults = repository.shareDefaults(), signedIn = repository.userKey().isNotBlank(), + privateBinInstance = repository.privateBinInstance(), ) if (_uiState.value.signedIn) refreshList() } @@ -87,14 +89,19 @@ class PastebinViewModel @Inject constructor( burnAfterRead = state.burnAfterRead, password = state.password, ), - // Burn-after-read is a guest-only feature on Pastebin. - underAccount = state.signedIn && !state.burnAfterRead, + underAccount = state.signedIn, ) _uiState.value = _uiState.value.copy( posting = false, lastUrl = result.getOrNull(), message = result.fold( - onSuccess = { "Paste created" }, + onSuccess = { + if (state.burnAfterRead || state.password.isNotBlank()) { + "Encrypted paste created on PrivateBin" + } else { + "Paste created" + } + }, onFailure = { it.message ?: "Could not create the paste" }, ), ) @@ -162,5 +169,10 @@ class PastebinViewModel @Inject constructor( viewModelScope.launch { repository.setShareDefaults(next) } } + fun onPrivateBinInstance(value: String) { + _uiState.value = _uiState.value.copy(privateBinInstance = value) + viewModelScope.launch { repository.setPrivateBinInstance(value) } + } + fun dismissMessage() { _uiState.value = _uiState.value.copy(message = null) } } diff --git a/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PastebinApi.kt b/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PastebinApi.kt index 976c1db..cd72ffb 100644 --- a/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PastebinApi.kt +++ b/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PastebinApi.kt @@ -10,17 +10,21 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton -/** How long a paste lives (Pastebin's `api_paste_expire_date` values). */ -enum class PasteExpiry(val apiValue: String, val label: String) { - NEVER("N", "Never"), - TEN_MINUTES("10M", "10 minutes"), - ONE_HOUR("1H", "1 hour"), - ONE_DAY("1D", "1 day"), - ONE_WEEK("1W", "1 week"), - TWO_WEEKS("2W", "2 weeks"), - ONE_MONTH("1M", "1 month"), - SIX_MONTHS("6M", "6 months"), - ONE_YEAR("1Y", "1 year"), +/** + * How long a paste lives. [apiValue] is Pastebin's `api_paste_expire_date`; + * [privateBinValue] is the nearest PrivateBin window, never rounded upwards so + * a burner never outlives what was asked for. + */ +enum class PasteExpiry(val apiValue: String, val privateBinValue: String, val label: String) { + NEVER("N", "never", "Never"), + TEN_MINUTES("10M", "10min", "10 minutes"), + ONE_HOUR("1H", "1hour", "1 hour"), + ONE_DAY("1D", "1day", "1 day"), + ONE_WEEK("1W", "1week", "1 week"), + TWO_WEEKS("2W", "1week", "2 weeks"), + ONE_MONTH("1M", "1month", "1 month"), + SIX_MONTHS("6M", "1month", "6 months"), + ONE_YEAR("1Y", "1year", "1 year"), } /** Who can see a paste (`api_paste_private`). */ @@ -38,9 +42,12 @@ data class PasteRequest( val visibility: PasteVisibility = PasteVisibility.UNLISTED, /** Pastebin's syntax id, e.g. "text", "kotlin", "json". */ val format: String = "text", - /** Burn-after-read. Pastebin only honours this for guest (non-logged-in) pastes. */ + /** + * Burn-after-read. Pastebin's developer API cannot do this, so a request + * with this set is routed to PrivateBin instead (see [PrivateBinClient]). + */ val burnAfterRead: Boolean = false, - /** Optional password prompt before the paste can be read. */ + /** Read password. Also PrivateBin-only, for the same reason. */ val password: String = "", ) @@ -71,36 +78,60 @@ class PastebinApi @Inject constructor() { .readTimeout(30, TimeUnit.SECONDS) .build() - /** Creates a paste; returns its URL. [userKey] posts it under the account. */ + /** + * Creates a paste; returns its URL. [userKey] posts it under the account, + * which is also the only way `private` visibility is accepted. + * + * Burn-after-read and password are deliberately absent: they are not part of + * this API, and silently dropping them was worse than routing elsewhere. + */ suspend fun createPaste( devKey: String, request: PasteRequest, userKey: String? = null, - ): Result = post( - url = POST_URL, - fields = buildMap { - put("api_dev_key", devKey) - put("api_option", "paste") - put("api_paste_code", request.content) - put("api_paste_name", request.title) - put("api_paste_expire_date", request.expiry.apiValue) - put("api_paste_private", request.visibility.apiValue.toString()) - put("api_paste_format", request.format) - if (request.burnAfterRead) put("api_paste_burn", "1") - if (request.password.isNotBlank()) put("api_paste_password", request.password) - if (!userKey.isNullOrBlank()) put("api_user_key", userKey) - }, - ).map { it.trim() } - - /** Exchanges account credentials for the user key needed by list/delete. */ + ): Result { + if (request.visibility == PasteVisibility.PRIVATE && userKey.isNullOrBlank()) { + return Result.failure(IllegalStateException("Private pastes need you to sign in first")) + } + return post( + url = POST_URL, + fields = buildMap { + put("api_dev_key", devKey) + put("api_option", "paste") + put("api_paste_code", request.content) + put("api_paste_name", request.title) + put("api_paste_expire_date", request.expiry.apiValue) + put("api_paste_private", request.visibility.apiValue.toString()) + put("api_paste_format", request.format) + if (!userKey.isNullOrBlank()) put("api_user_key", userKey) + }, + ).mapCatching { text -> + val url = text.trim() + // Maintenance windows answer 200 with an HTML page, so insist on a URL. + if (!url.startsWith("https://pastebin.com/")) { + error(describe(url)) + } + url + } + } + + /** + * Exchanges account credentials for the user key needed by list/delete and + * by posting under the account. The reply is the bare key, so anything that + * is not key-shaped is an error page and gets reported as one. + */ suspend fun login(devKey: String, username: String, password: String): Result = post( url = LOGIN_URL, fields = mapOf( "api_dev_key" to devKey, - "api_user_name" to username, + "api_user_name" to username.trim(), "api_user_password" to password, ), - ).map { it.trim() } + ).mapCatching { text -> + val key = text.trim() + if (!USER_KEY.matches(key)) error(describe(key)) + key + } /** The account's pastes, newest first. */ suspend fun listPastes(devKey: String, userKey: String, limit: Int = 50): Result> = @@ -178,6 +209,18 @@ class PastebinApi @Inject constructor() { ) }.toList() + /** Turns whatever came back into something worth showing the user. */ + private fun describe(body: String): String { + val flat = body.replace(Regex("<[^>]*>"), " ").replace(Regex("\\s+"), " ").trim() + return when { + flat.isBlank() -> "Pastebin returned an empty response" + flat.contains("READ-ONLY", ignoreCase = true) || flat.contains("maintenance", ignoreCase = true) -> + "Pastebin is in read-only maintenance right now - try again in a few minutes" + flat.startsWith("Bad API request") -> flat.removePrefix("Bad API request,").trim() + else -> flat.take(160) + } + } + companion object { /** Personal developer key for this sideloaded build (§Module Pastebin). */ const val DEFAULT_DEV_KEY = "RMdq0LvnD38fn3jDoLtnE4k7zSlu6FTJ" @@ -193,5 +236,6 @@ class PastebinApi @Inject constructor() { private const val RAW_URL = "https://pastebin.com/api/api_raw.php" private const val TAG = "PastebinApi" private val PASTE_BLOCK = Regex("(.*?)", RegexOption.DOT_MATCHES_ALL) + private val USER_KEY = Regex("[A-Za-z0-9]{16,64}") } } diff --git a/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PastebinJarvisBridge.kt b/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PastebinJarvisBridge.kt new file mode 100644 index 0000000..b72eea6 --- /dev/null +++ b/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PastebinJarvisBridge.kt @@ -0,0 +1,90 @@ +package com.lifeos.feature.pastebin.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 javax.inject.Inject + +/** Pastebin as Jarvis reads it: the account's pastes and the share defaults. */ +internal class PastebinProvider @Inject constructor( + private val repository: PastebinRepository, +) : LifeDataProvider { + + override val topic: String = "pastes" + override val description: String = "your Pastebin pastes and the burner defaults" + + override suspend fun read(query: String?): String { + val defaults = repository.shareDefaults() + val signedIn = repository.userKey().isNotBlank() + val list = if (signedIn) repository.list().getOrNull().orEmpty() else emptyList() + return buildString { + appendLine("Pastebin: ${if (signedIn) "signed in" else "not signed in (guest pastes only)"}") + appendLine( + "Share defaults: expiry ${defaults.expiry.label}, ${defaults.visibility.label}" + + (if (defaults.burnAfterRead) ", burn after read" else "") + + (if (defaults.password.isNotBlank()) ", password set" else ""), + ) + if (list.isEmpty()) { + appendLine("No pastes listed.") + } else { + appendLine("Recent pastes:") + list.take(8).forEach { paste -> + appendLine("- ${paste.title} (${paste.visibility}, ${paste.hits} hits) ${paste.url}") + } + } + }.trim() + } +} + +/** Creating pastes on Jarvis's word, including encrypted burners. */ +internal class PastebinActionHandler @Inject constructor( + private val repository: PastebinRepository, + private val echo: ActionEcho, +) : LifeActionHandler { + + override fun canHandle(action: LifeAction): Boolean = action is LifeAction.CreatePaste + + override suspend fun execute(action: LifeAction): LifeResult { + val create = action as LifeAction.CreatePaste + val defaults = repository.shareDefaults() + val result = repository.create( + PasteRequest( + title = create.title.ifBlank { "From Jarvis" }.take(80), + content = create.content, + expiry = defaults.expiry, + visibility = defaults.visibility, + burnAfterRead = create.burner, + password = create.password, + ), + underAccount = repository.userKey().isNotBlank(), + ) + return result.fold( + onSuccess = { url -> + echo.url(url) + LifeResult.Success(null) + }, + onFailure = { LifeResult.Failure(LifeError.Unknown(it.message ?: "Paste failed")) }, + ) + } +} + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class PastebinJarvisModule { + + @Binds + @IntoSet + abstract fun bindProvider(impl: PastebinProvider): LifeDataProvider + + @Binds + @IntoSet + abstract fun bindHandler(impl: PastebinActionHandler): LifeActionHandler +} diff --git a/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PastebinRepository.kt b/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PastebinRepository.kt index 389c4e2..f809be7 100644 --- a/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PastebinRepository.kt +++ b/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PastebinRepository.kt @@ -43,6 +43,7 @@ data class ShareDefaults( @Singleton class PastebinRepository @Inject constructor( private val api: PastebinApi, + private val privateBin: PrivateBinClient, private val settingsRepository: SettingsRepository, ) { @@ -64,11 +65,42 @@ class PastebinRepository @Inject constructor( suspend fun signOut() = settingsRepository.setPastebinUserKey("") + /** + * Creates a paste on whichever backend can actually honour the request. + * + * Burn-after-read and passwords do not exist in Pastebin's developer API, so + * those go to PrivateBin (end-to-end encrypted, key in the link fragment). + * Everything else goes to Pastebin, under the account when signed in. + */ suspend fun create(request: PasteRequest, underAccount: Boolean): Result { + if (request.burnAfterRead || request.password.isNotBlank()) { + return privateBin.create( + instance = privateBinInstance(), + text = if (request.title.isBlank()) { + request.content + } else { + "${request.title}\n\n${request.content}" + }, + expiry = request.expiry, + burnAfterRead = request.burnAfterRead, + password = request.password, + markdown = request.format == "markdown", + ) + } val key = if (underAccount) userKey().ifBlank { null } else null return api.createPaste(devKey, request, key) } + suspend fun privateBinInstance(): String = + settingsRepository.privateBinInstance.first().ifBlank { PrivateBinClient.DEFAULT_INSTANCE } + + suspend fun setPrivateBinInstance(url: String) = + settingsRepository.setPrivateBinInstance(url.trim()) + + /** Which backend a request would land on, for the UI to say so up front. */ + fun backendFor(burnAfterRead: Boolean, password: String): String = + if (burnAfterRead || password.isNotBlank()) "PrivateBin" else "Pastebin" + /** Creates a paste using the saved share-sheet defaults. */ suspend fun createFromShare(title: String, content: String): Result { val defaults = shareDefaults() @@ -81,9 +113,7 @@ class PastebinRepository @Inject constructor( burnAfterRead = defaults.burnAfterRead, password = defaults.password, ), - // Burn-after-read only works for guest pastes, so a burner paste is - // deliberately posted without the account key. - underAccount = !defaults.burnAfterRead, + underAccount = true, ) } diff --git a/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PrivateBinClient.kt b/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PrivateBinClient.kt new file mode 100644 index 0000000..6b7215f --- /dev/null +++ b/feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PrivateBinClient.kt @@ -0,0 +1,176 @@ +package com.lifeos.feature.pastebin.data + +import com.lifeos.core.common.log.LifeLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.math.BigInteger +import java.security.SecureRandom +import java.util.Base64 +import java.util.concurrent.TimeUnit +import java.util.zip.Deflater +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 + +/** + * Burner pastes via PrivateBin (§Module Pastebin). + * + * Pastebin's developer API has no burn-after-read and no paste password - those + * only exist in its web UI - so anything that must self-destruct or be password + * protected goes to a PrivateBin instance instead. PrivateBin is end-to-end + * encrypted: the key never leaves the phone, it travels in the link fragment, + * and the server only ever holds ciphertext. + * + * Format (PrivateBin v2): AES-256-GCM over raw-deflated JSON, key material is + * PBKDF2-HMAC-SHA256 over (random key + password), and the parameter array is + * also the AEAD associated data - so it is built as an exact string and reused + * verbatim in the request. + */ +@Singleton +class PrivateBinClient @Inject constructor() { + + private val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val random = SecureRandom() + + /** + * Creates an encrypted paste. + * + * @return the shareable URL, including the decryption key in the fragment. + */ + suspend fun create( + instance: String = DEFAULT_INSTANCE, + text: String, + expiry: PasteExpiry, + burnAfterRead: Boolean, + password: String, + markdown: Boolean = false, + ): Result = withContext(Dispatchers.IO) { + runCatching { + val key = ByteArray(32).also(random::nextBytes) + val iv = ByteArray(16).also(random::nextBytes) + val salt = ByteArray(8).also(random::nextBytes) + + val spec = buildString { + append('[') + append('"').append(b64(iv)).append('"').append(',') + append('"').append(b64(salt)).append('"').append(',') + append(ITERATIONS).append(',') + append(KEY_BITS).append(',') + append(TAG_BITS).append(',') + append("\"aes\",\"gcm\",\"zlib\"") + append(']') + } + val formatting = if (markdown) "markdown" else "plaintext" + // Exactly the bytes the server will store, and exactly the AAD. + val adata = "[$spec,\"$formatting\",0,${if (burnAfterRead) 1 else 0}]" + + val payload = JSONObject().put("paste", text).toString().toByteArray() + val compressed = deflateRaw(payload) + val derived = pbkdf2(key + password.toByteArray(), salt, ITERATIONS, KEY_BITS / 8) + val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(Cipher.ENCRYPT_MODE, SecretKeySpec(derived, "AES"), GCMParameterSpec(TAG_BITS, iv)) + updateAAD(adata.toByteArray()) + } + val ct = cipher.doFinal(compressed) + + val body = """{"v":2,"adata":$adata,"ct":"${b64(ct)}","meta":{"expire":"${expiry.privateBinValue}"}}""" + val base = instance.trim().trimEnd('/').ifBlank { DEFAULT_INSTANCE } + val request = Request.Builder() + .url("$base/") + // PrivateBin only speaks its JSON API when asked to. + .header("X-Requested-With", "JSONHttpRequest") + .post(body.toRequestBody(JSON)) + .build() + val response = client.newCall(request).execute().use { it.body?.string().orEmpty() } + val json = runCatching { JSONObject(response) } + .getOrElse { error("${hostOf(base)} did not answer with JSON") } + if (json.optInt("status", 1) != 0) { + error(json.optString("message").ifBlank { "PrivateBin rejected the paste" }) + } + val id = json.optString("id").ifBlank { error("PrivateBin returned no paste id") } + "$base/?$id#${base58(key)}" + }.onFailure { LifeLogger.w(TAG, "PrivateBin create failed", it) } + } + + // ---- crypto and encoding helpers --------------------------------------- + + /** PBKDF2-HMAC-SHA256 over raw bytes (PBEKeySpec would re-encode them). */ + private fun pbkdf2(material: ByteArray, salt: ByteArray, iterations: Int, lengthBytes: Int): ByteArray { + val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(material, "HmacSHA256")) } + val blockSize = mac.macLength + val blocks = (lengthBytes + blockSize - 1) / blockSize + val output = ByteArray(blocks * blockSize) + var offset = 0 + for (block in 1..blocks) { + var u = mac.doFinal(salt + byteArrayOf((block ushr 24).toByte(), (block ushr 16).toByte(), (block ushr 8).toByte(), block.toByte())) + val t = u.copyOf() + repeat(iterations - 1) { + u = mac.doFinal(u) + for (i in t.indices) t[i] = (t[i].toInt() xor u[i].toInt()).toByte() + } + t.copyInto(output, offset) + offset += blockSize + } + return output.copyOf(lengthBytes) + } + + /** Raw deflate (no zlib header), which is what PrivateBin's "zlib" means. */ + private fun deflateRaw(data: ByteArray): ByteArray { + val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, true) + return try { + deflater.setInput(data) + deflater.finish() + val buffer = ByteArray(4096) + val out = java.io.ByteArrayOutputStream() + while (!deflater.finished()) { + val n = deflater.deflate(buffer) + out.write(buffer, 0, n) + } + out.toByteArray() + } finally { + deflater.end() + } + } + + private fun b64(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) + + /** Bitcoin-style base58, the alphabet PrivateBin uses for the key fragment. */ + private fun base58(bytes: ByteArray): String { + var value = BigInteger(1, bytes) + val fifty8 = BigInteger.valueOf(58) + val builder = StringBuilder() + while (value > BigInteger.ZERO) { + val (quotient, remainder) = value.divideAndRemainder(fifty8) + builder.append(ALPHABET[remainder.toInt()]) + value = quotient + } + bytes.takeWhile { it == 0.toByte() }.forEach { _ -> builder.append(ALPHABET[0]) } + return builder.reverse().toString() + } + + private fun hostOf(url: String): String = + runCatching { java.net.URI(url).host.orEmpty() }.getOrDefault(url) + + companion object { + /** Default public instance; changeable in the module's settings tab. */ + const val DEFAULT_INSTANCE = "https://privatebin.net" + private const val TAG = "PrivateBinClient" + private const val ITERATIONS = 100_000 + private const val KEY_BITS = 256 + private const val TAG_BITS = 128 + private const val ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + private val JSON = "application/json".toMediaType() + } +} diff --git a/feature/plants/src/main/kotlin/com/lifeos/feature/plants/data/PlantsJarvisBridge.kt b/feature/plants/src/main/kotlin/com/lifeos/feature/plants/data/PlantsJarvisBridge.kt new file mode 100644 index 0000000..4609b9f --- /dev/null +++ b/feature/plants/src/main/kotlin/com/lifeos/feature/plants/data/PlantsJarvisBridge.kt @@ -0,0 +1,103 @@ +package com.lifeos.feature.plants.data + +import com.lifeos.core.common.result.LifeError +import com.lifeos.core.common.result.LifeResult +import com.lifeos.core.database.plants.MyPlantEntity +import com.lifeos.core.database.plants.PlantDao +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 javax.inject.Inject + +/** Plants as Jarvis reads them: what is on the shelf and what is thirsty. */ +internal class PlantsProvider @Inject constructor( + private val plantDao: PlantDao, +) : LifeDataProvider { + + override val topic: String = "plants" + override val description: String = "your plants, watering intervals and what is overdue" + + override suspend fun read(query: String?): String { + val plants = plantDao.observeAll().first() + if (plants.isEmpty()) return "Plants: none on the shelf yet." + val now = System.currentTimeMillis() + return buildString { + appendLine("Plants (${plants.size}):") + plants.forEach { plant -> + val dueIn = plant.dueInDays(now) + val state = when { + plant.lastWateredAt == null -> "never watered" + dueIn != null && dueIn < 0 -> "overdue by ${-dueIn}d" + dueIn == 0 -> "due today" + else -> "due in ${dueIn}d" + } + appendLine("- ${plant.name} (${plant.speciesId}, every ${plant.waterEveryDays}d): $state") + } + }.trim() + } + + private fun MyPlantEntity.dueInDays(now: Long): Int? { + val last = lastWateredAt ?: return null + val elapsedDays = ((now - last) / 86_400_000L).toInt() + return waterEveryDays - elapsedDays + } +} + +/** Watering and adding plants on Jarvis's word. */ +internal class PlantsActionHandler @Inject constructor( + private val plantDao: PlantDao, +) : LifeActionHandler { + + override fun canHandle(action: LifeAction): Boolean = + action is LifeAction.WaterPlant || action is LifeAction.AddPlant + + override suspend fun execute(action: LifeAction): LifeResult = when (action) { + is LifeAction.WaterPlant -> { + val plants = plantDao.observeAll().first() + val needle = action.plantName.trim().lowercase() + val plant = plants.firstOrNull { it.name.lowercase() == needle } + ?: plants.firstOrNull { needle in it.name.lowercase() } + if (plant == null) { + LifeResult.Failure(LifeError.Validation("No plant called \"${action.plantName}\"")) + } else { + plantDao.setWatered(plant.id, System.currentTimeMillis()) + LifeResult.Success(plant.id) + } + } + + is LifeAction.AddPlant -> { + val id = plantDao.insert( + MyPlantEntity( + name = action.plantName.trim().take(60), + speciesId = action.species.trim().ifBlank { "unknown" }, + waterEveryDays = action.waterEveryDays.coerceIn(1, 120), + lastWateredAt = null, + reminderId = null, + createdAt = System.currentTimeMillis(), + ), + ) + LifeResult.Success(id) + } + + else -> LifeResult.Failure(LifeError.Validation("Unsupported action")) + } +} + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class PlantsJarvisModule { + + @Binds + @IntoSet + abstract fun bindProvider(impl: PlantsProvider): LifeDataProvider + + @Binds + @IntoSet + abstract fun bindHandler(impl: PlantsActionHandler): LifeActionHandler +} diff --git a/feature/screentime/build.gradle.kts b/feature/screentime/build.gradle.kts index 4fed39b..cb90473 100644 --- a/feature/screentime/build.gradle.kts +++ b/feature/screentime/build.gradle.kts @@ -7,6 +7,7 @@ plugins { dependencies { implementation(projects.core.common) + implementation(projects.core.service) implementation(projects.core.designsystem) implementation(projects.core.database) implementation(projects.core.datastore) diff --git a/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/ScreenTimeViewModel.kt b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/ScreenTimeViewModel.kt index 21d82c2..8f80251 100644 --- a/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/ScreenTimeViewModel.kt +++ b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/ScreenTimeViewModel.kt @@ -6,6 +6,8 @@ import com.lifeos.core.database.screentime.AppUsageEntity import com.lifeos.core.database.screentime.ScreenTimeDao import com.lifeos.core.database.screentime.ScreenTimeDayEntity import com.lifeos.core.datastore.SettingsRepository +import com.lifeos.feature.screentime.data.ScreenTimeExportFormat +import com.lifeos.feature.screentime.data.ScreenTimeExporter import com.lifeos.feature.screentime.data.ScreenTimeCollector import kotlinx.coroutines.flow.first import dagger.hilt.android.lifecycle.HiltViewModel @@ -52,6 +54,7 @@ data class ScreenTimeUiState( @HiltViewModel class ScreenTimeViewModel @Inject constructor( + private val exporter: ScreenTimeExporter, private val dao: ScreenTimeDao, private val collector: ScreenTimeCollector, private val settingsRepository: SettingsRepository, @@ -117,31 +120,16 @@ class ScreenTimeViewModel @Inject constructor( /** Builds the requested export body; the screen writes it to Downloads. */ suspend fun buildExport(format: ExportFormat, weekOnly: Boolean): Pair { - val days = if (weekOnly) { - val keys = _uiState.value.days.map { it.date }.toSet() - dao.allDays().filter { it.date in keys } - } else { - dao.allDays() - } - val dayKeys = days.map { it.date }.toSet() - val apps = dao.allApps().filter { it.date in dayKeys } - val suffix = if (weekOnly) "week" else "all" - return when (format) { - ExportFormat.JSON -> "lifeos-screentime-$suffix.json" to exportJson(days, apps) - ExportFormat.CSV_DAYS -> "lifeos-screentime-days-$suffix.csv" to buildString { - appendLine("date,screen_time_minutes,unlocks,notifications") - days.sortedBy { it.date }.forEach { - appendLine("${it.date},${it.totalForegroundMs / 60_000},${it.unlocks},${it.notifications}") - } - } - ExportFormat.CSV_APPS -> "lifeos-screentime-apps-$suffix.csv" to buildString { - appendLine("date,app,package,minutes") - apps.sortedWith(compareBy({ it.date }, { -it.foregroundMs })).forEach { - val label = it.label.replace(",", " ") - appendLine("${it.date},$label,${it.packageName},${it.foregroundMs / 60_000}") - } - } - } + val keys = if (weekOnly) _uiState.value.days.map { it.date }.toSet() else null + return exporter.build( + format = when (format) { + ExportFormat.JSON -> ScreenTimeExportFormat.JSON + ExportFormat.CSV_DAYS -> ScreenTimeExportFormat.CSV_DAYS + ExportFormat.CSV_APPS -> ScreenTimeExportFormat.CSV_APPS + }, + dateKeys = keys, + suffix = if (weekOnly) "week" else "all", + ) } fun onExported(fileName: String?) { @@ -197,35 +185,4 @@ class ScreenTimeViewModel @Inject constructor( } /** JSON body for the given rows. */ - private fun exportJson( - days: List, - appRows: List, - ): String { - val apps = appRows.groupBy { it.date } - val json = Json { prettyPrint = true } - val array = JsonArray( - days.map { day -> - JsonObject( - mapOf( - "date" to JsonPrimitive(day.date), - "totalForegroundMs" to JsonPrimitive(day.totalForegroundMs), - "unlocks" to JsonPrimitive(day.unlocks), - "notifications" to JsonPrimitive(day.notifications), - "apps" to JsonArray( - (apps[day.date] ?: emptyList()).sortedByDescending { it.foregroundMs }.map { - JsonObject( - mapOf( - "package" to JsonPrimitive(it.packageName), - "label" to JsonPrimitive(it.label), - "foregroundMs" to JsonPrimitive(it.foregroundMs), - ), - ) - }, - ), - ), - ) - }, - ) - return json.encodeToString(JsonArray.serializer(), array) - } } diff --git a/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/data/ScreenTimeExporter.kt b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/data/ScreenTimeExporter.kt new file mode 100644 index 0000000..c7510aa --- /dev/null +++ b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/data/ScreenTimeExporter.kt @@ -0,0 +1,98 @@ +package com.lifeos.feature.screentime.data + +import com.lifeos.core.database.screentime.AppUsageEntity +import com.lifeos.core.database.screentime.ScreenTimeDao +import com.lifeos.core.database.screentime.ScreenTimeDayEntity +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import javax.inject.Inject +import javax.inject.Singleton + +/** Export formats offered by the module and by Jarvis. */ +enum class ScreenTimeExportFormat { JSON, CSV_DAYS, CSV_APPS } + +/** + * Builds screen-time exports (§Module Screen Time). Shared by the export dialog + * and by Jarvis's `[[export_screen_time: …]]` tool so both produce byte-identical + * files. + */ +@Singleton +class ScreenTimeExporter @Inject constructor( + private val dao: ScreenTimeDao, +) { + + /** @return file name to body. [dateKeys] limits the range; null = everything. */ + suspend fun build( + format: ScreenTimeExportFormat, + dateKeys: Set?, + suffix: String, + ): Pair { + val days = dao.allDays().let { all -> if (dateKeys == null) all else all.filter { it.date in dateKeys } } + val keys = days.map { it.date }.toSet() + val apps = dao.allApps().filter { it.date in keys } + return when (format) { + ScreenTimeExportFormat.JSON -> + "lifeos-screentime-$suffix.json" to json(days, apps) + + ScreenTimeExportFormat.CSV_DAYS -> + "lifeos-screentime-days-$suffix.csv" to buildString { + appendLine("date,screen_time_minutes,unlocks,notifications") + days.sortedBy { it.date }.forEach { + appendLine("${it.date},${it.totalForegroundMs / 60_000},${it.unlocks},${it.notifications}") + } + } + + ScreenTimeExportFormat.CSV_APPS -> + "lifeos-screentime-apps-$suffix.csv" to buildString { + appendLine("date,app,package,minutes") + apps.sortedWith(compareBy({ it.date }, { -it.foregroundMs })).forEach { + appendLine( + "${it.date},${it.label.replace(",", " ")},${it.packageName}," + + "${it.foregroundMs / 60_000}", + ) + } + } + } + } + + /** Tool-friendly entry point: accepts the format as free text. */ + suspend fun build(format: String, weekOnly: Boolean): Pair { + val parsed = when { + format.contains("app", ignoreCase = true) -> ScreenTimeExportFormat.CSV_APPS + format.contains("csv", ignoreCase = true) -> ScreenTimeExportFormat.CSV_DAYS + else -> ScreenTimeExportFormat.JSON + } + val keys = if (weekOnly) dao.allDays().take(7).map { it.date }.toSet() else null + return build(parsed, keys, if (weekOnly) "week" else "all") + } + + private fun json(days: List, appRows: List): String { + val apps = appRows.groupBy { it.date } + val array = JsonArray( + days.map { day -> + JsonObject( + mapOf( + "date" to JsonPrimitive(day.date), + "totalForegroundMs" to JsonPrimitive(day.totalForegroundMs), + "unlocks" to JsonPrimitive(day.unlocks), + "notifications" to JsonPrimitive(day.notifications), + "apps" to JsonArray( + (apps[day.date] ?: emptyList()).sortedByDescending { it.foregroundMs }.map { + JsonObject( + mapOf( + "package" to JsonPrimitive(it.packageName), + "label" to JsonPrimitive(it.label), + "foregroundMs" to JsonPrimitive(it.foregroundMs), + ), + ) + }, + ), + ), + ) + }, + ) + return Json { prettyPrint = true }.encodeToString(JsonArray.serializer(), array) + } +} diff --git a/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/data/ScreenTimeJarvisBridge.kt b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/data/ScreenTimeJarvisBridge.kt new file mode 100644 index 0000000..c3d4ebe --- /dev/null +++ b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/data/ScreenTimeJarvisBridge.kt @@ -0,0 +1,107 @@ +package com.lifeos.feature.screentime.data + +import android.content.ContentValues +import android.content.Context +import android.provider.MediaStore +import com.lifeos.core.common.result.LifeError +import com.lifeos.core.common.result.LifeResult +import com.lifeos.core.database.screentime.ScreenTimeDao +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.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import javax.inject.Inject + +/** Screen Time as Jarvis reads it (§Module 9): totals, trend, worst apps. */ +internal class ScreenTimeProvider @Inject constructor( + private val screenTimeDao: ScreenTimeDao, +) : LifeDataProvider { + + override val topic: String = "screen_time" + override val description: String = "phone usage per day, unlocks, top apps" + + override suspend fun read(query: String?): String { + val days = query?.filter { it.isDigit() }?.toIntOrNull()?.coerceIn(1, 60) ?: 7 + val rows = screenTimeDao.allDays().take(days) + if (rows.isEmpty()) return "Screen time: nothing recorded yet (grant Usage access in the module)." + val total = rows.sumOf { it.totalForegroundMs } + val apps = screenTimeDao.appsBetween(rows.last().date, rows.first().date) + .groupBy { it.packageName } + .map { (_, rowsForApp) -> rowsForApp.first().label to rowsForApp.sumOf { it.foregroundMs } } + .sortedByDescending { it.second } + .take(6) + return buildString { + appendLine("Screen time, last ${rows.size} day(s):") + appendLine("- total ${human(total)}, average ${human(total / rows.size)}/day") + appendLine("- unlocks ${rows.sumOf { it.unlocks }}") + rows.take(7).forEach { day -> + appendLine("- ${day.date}: ${human(day.totalForegroundMs)} (${day.unlocks} unlocks)") + } + if (apps.isNotEmpty()) { + appendLine("Top apps: " + apps.joinToString("; ") { "${it.first} ${human(it.second)}" }) + } + }.trim() + } + + private fun human(ms: Long): String { + val minutes = ms / 60_000 + return if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" + } +} + +/** Screen Time actions Jarvis can take: sync now, export to Downloads. */ +internal class ScreenTimeActionHandler @Inject constructor( + @ApplicationContext private val context: Context, + private val collector: ScreenTimeCollector, + private val exporter: ScreenTimeExporter, + private val echo: ActionEcho, +) : LifeActionHandler { + + override fun canHandle(action: LifeAction): Boolean = + action is LifeAction.SyncScreenTime || action is LifeAction.ExportScreenTime + + override suspend fun execute(action: LifeAction): LifeResult = when (action) { + is LifeAction.SyncScreenTime -> { + if (!collector.hasPermission()) { + LifeResult.Failure(LifeError.Validation("Usage access is not granted yet")) + } else { + collector.sync() + LifeResult.Success(null) + } + } + + is LifeAction.ExportScreenTime -> { + val (name, body) = exporter.build(action.format, action.weekOnly) + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, name) + put(MediaStore.Downloads.MIME_TYPE, if (name.endsWith(".csv")) "text/csv" else "application/json") + } + val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + ?: return LifeResult.Failure(LifeError.Unknown("Downloads folder refused the file")) + context.contentResolver.openOutputStream(uri)?.use { it.write(body.toByteArray()) } + echo.fileName(name) + LifeResult.Success(null) + } + + else -> LifeResult.Failure(LifeError.Validation("Unsupported action")) + } +} + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class ScreenTimeJarvisModule { + + @Binds + @IntoSet + abstract fun bindProvider(impl: ScreenTimeProvider): LifeDataProvider + + @Binds + @IntoSet + abstract fun bindHandler(impl: ScreenTimeActionHandler): LifeActionHandler +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec2e629..4ecb9a1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,6 +101,8 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx- okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } mediapipe-tasks-genai = { group = "com.google.mediapipe", name = "tasks-genai", version.ref = "tasksGenai" } +# MPImage/BitmapImageBuilder for on-device vision prompts; not bundled with tasks-genai. +mediapipe-tasks-vision = { group = "com.google.mediapipe", name = "tasks-vision", version.ref = "tasksGenai" } markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" } androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" } androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }