Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ and rule traces back to a section (and often a community demand source) there.
| Focus timer keeps running: state lives in a singleton driven by an absolute deadline, so switching tabs, going Home or leaving the app no longer resets it. The time in the ring is edited in place (no dialog), and the overlay can be dragged anywhere and pinched to resize; closing it with its X now updates the in-app button | Done |
| Pastebin honesty pass: its developer API has neither burn-after-read nor paste passwords, so those requests now go to PrivateBin instead - AES-256-GCM encrypted on the phone, key in the link fragment, verified against a live instance. Maintenance pages and bad logins are reported instead of being mistaken for success, and signed-in pastes land in the account | Done |
| Home tiles auto-scroll while you drag one near the top or bottom edge | Done |
| Triggers (new module): visible automation rules - when a time, place arrival/exit, Wi-Fi, NFC tag, notification, screen-time threshold, battery level or reminder happens, do any LifeOS action or macro. Day-of-week and window conditions, a one-a-minute cooldown, run-by-hand testing, and a fire log that answers "why did that happen". Jarvis reads and writes rules | Done |
| Places (new core module): home/work/gym recognition without Play Services - Wi-Fi network match plus last-known-location inside a radius, polled every two minutes with no active GPS fix. Publishes enter/leave on the event bus, which is what Triggers listens to | Done |
| Signals (new module): notification capture with dedupe, code/parcel/receipt tagging, a "what did I miss" digest and 30-day trimming, behind a single notification-access grant. Jarvis answers from it and rules can fire on it | Done |
| Recall (new core module): semantic index over notes, captures, memex clips, chat history and readable /LifeOS files, embedded on-device and refreshed incrementally. Jarvis searches it with `[[get: recall \| query]]` instead of keyword matching | Done |
| Sync (new module): encrypted database snapshots (VACUUM INTO, AES-256-GCM from a passphrase), kept in generations locally and pushed to WebDAV on the NAS, each run read back and decrypted to verify it. Restore is staged and applied before Room opens on the next cold start | Done |
| Jarvis core v2: token streaming through MediaPipe's progress listener (first-token latency instead of total), multi-hop tool use (up to two module reads per turn, each answered in a fresh pass) and cleaned final text so raw fragments never show | Done |
| Plan: auto-schedules due tasks into real calendar gaps inside working hours as ordinary, movable events; Jarvis plans today or tomorrow on request | Done |
| Surfaces: a "Next up" Glance home-screen widget plus quick-settings tiles for capture and a 25-minute focus block | Done |
| Voice: replies can be spoken through the device's on-device speech engine, and any module (or Jarvis) can say something with a Speak action | Done |
| Deferred post-alpha: Glance home-screen widgets, HA WebSocket live state/zones, Vault unlock UI, first-run onboarding checklist (grants live in Settings → System access), FinTS bank sync | Planned |

**Google-free by design:** no Google service is ever called at runtime (no Play Services, no Google recognizer, no Google Maps). Remaining Google-*authored* open-source, fully on-device libraries: AndroidX/Jetpack (unavoidable on Android), MediaPipe (Gemma inference), ML Kit on-device OCR/barcode (no network) — swap candidates documented in the plan.
Expand Down
13 changes: 11 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ android {

defaultConfig {
applicationId = "com.lifeos"
versionCode = 22
versionName = "0.1.0-alpha.22"
versionCode = 23
versionName = "0.1.0-alpha.23"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

Expand Down Expand Up @@ -89,6 +89,13 @@ dependencies {
implementation(projects.feature.brick)
implementation(projects.feature.pastebin)
implementation(projects.feature.clearsky)
implementation(projects.feature.triggers)
implementation(projects.feature.signals)
implementation(projects.feature.sync)
implementation(projects.core.places)
implementation(projects.core.recall)
implementation(projects.core.voice)


implementation(projects.core.model)
implementation(projects.core.network)
Expand All @@ -103,6 +110,8 @@ dependencies {

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
Expand Down
34 changes: 34 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,40 @@
</intent-filter>
</activity>

<!-- Home-screen widget and quick-settings tiles (§Module Surfaces). -->
<receiver
android:name="com.lifeos.app.surfaces.NextUpWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/next_up_widget_info" />
</receiver>

<service
android:name="com.lifeos.app.surfaces.QuickCaptureTileService"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="LifeOS capture"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>

<service
android:name="com.lifeos.app.surfaces.FocusTileService"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="Focus 25 min"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>

</application>

</manifest>
38 changes: 38 additions & 0 deletions app/src/main/kotlin/com/lifeos/app/LifeOsApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@ package com.lifeos.app
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.lifeos.core.database.LifeDatabase
import com.lifeos.core.places.PlaceEngine
import com.lifeos.core.recall.RecallIndex
import com.lifeos.feature.dhl.work.PackagePollWorker
import com.lifeos.feature.sync.data.BackupService
import com.lifeos.feature.triggers.data.TriggerEngine
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltAndroidApp
Expand All @@ -13,11 +23,39 @@ class LifeOsApplication : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory

@Inject
lateinit var triggerEngine: TriggerEngine

@Inject
lateinit var placeEngine: PlaceEngine

@Inject
lateinit var recallIndex: RecallIndex

private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

override val workManagerConfiguration: Configuration
get() = Configuration.Builder().setWorkerFactory(workerFactory).build()

override fun onCreate() {
// A staged restore has to be applied before anything opens the database,
// which is why this is the very first thing the process does.
BackupService.applyStagedRestore(this, LifeDatabase.NAME)
super.onCreate()
PackagePollWorker.schedule(this)
// Rules and place matching are the two things that must run whether or
// not their screens were ever opened.
placeEngine.start()
triggerEngine.start()
startupScope.launch {
// Poll the state-ish triggers (Wi-Fi, battery) and keep the semantic
// index warm, both well after start-up so nothing competes with it.
delay(20_000)
while (true) {
runCatching { triggerEngine.poll() }
runCatching { recallIndex.reindex() }
delay(15 * 60_000L)
}
}
}
}
72 changes: 72 additions & 0 deletions app/src/main/kotlin/com/lifeos/app/surfaces/LifeOsTiles.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.lifeos.app.surfaces

import android.content.Intent
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import com.lifeos.app.MainActivity
import com.lifeos.core.model.LifeModule
import com.lifeos.core.model.SourceRef
import com.lifeos.core.service.LifeAction
import com.lifeos.core.service.LifeActionDispatcher
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject

/**
* Quick-settings tiles (§Module Surfaces): the two things worth doing without
* opening anything - capture a thought, and start a focus block.
*/
@AndroidEntryPoint
class QuickCaptureTileService : TileService() {

override fun onStartListening() {
qsTile?.apply {
state = Tile.STATE_INACTIVE
label = "LifeOS capture"
updateTile()
}
}

override fun onClick() {
val intent = Intent(this, MainActivity::class.java)
.putExtra(EXTRA_QUICK_CAPTURE, true)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)
}

companion object {
const val EXTRA_QUICK_CAPTURE = "lifeos.quick_capture"
}
}

/** Starts a 25-minute focus block straight from the shade. */
@AndroidEntryPoint
class FocusTileService : TileService() {

@Inject
lateinit var dispatcher: LifeActionDispatcher

private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

override fun onStartListening() {
qsTile?.apply {
state = Tile.STATE_INACTIVE
label = "Focus 25 min"
updateTile()
}
}

override fun onClick() {
scope.launch {
dispatcher.dispatch(LifeAction.StartFocusTimer(25, SourceRef(LifeModule.ADHD, "tile")))
}
qsTile?.apply {
state = Tile.STATE_ACTIVE
updateTile()
}
}
}
97 changes: 97 additions & 0 deletions app/src/main/kotlin/com/lifeos/app/surfaces/NextUpWidget.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.lifeos.app.surfaces

import android.content.Context
import androidx.compose.runtime.Composable
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Column
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.padding
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.glance.unit.ColorProvider
import com.lifeos.app.MainActivity
import com.lifeos.core.database.calendar.CalendarDao
import com.lifeos.core.database.capture.CaptureDao
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.first
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

/**
* "Next up" home-screen widget (§Module Surfaces).
*
* Reads the same DAOs the app does through a Hilt entry point - a widget is not
* an Android component Hilt can inject directly. Tapping it opens LifeOS.
*/
class NextUpWidget : GlanceAppWidget() {

override suspend fun provideGlance(context: Context, id: GlanceId) {
val lines = loadLines(context)
provideContent {
WidgetBody(lines)
}
}

@Composable
private fun WidgetBody(lines: List<String>) {
GlanceTheme {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.widgetBackground)
.padding(12.dp)
.clickable(actionStartActivity<MainActivity>()),
) {
Text(
"Next up",
style = TextStyle(color = ColorProvider(TITLE)),
)
lines.forEach { line ->
Text(line, style = TextStyle(color = GlanceTheme.colors.onSurface))
}
}
}
}

private suspend fun loadLines(context: Context): List<String> = runCatching {
val entryPoint = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java)
val now = System.currentTimeMillis()
val events = entryPoint.calendarDao().observeUpcoming(now, limit = 2).first()
val tasks = entryPoint.captureDao().observeTasks().first().filter { !it.done }
buildList {
events.forEach { add("${AT.format(Date(it.startsAt))} ${it.title.take(28)}") }
tasks.take(3 - events.size.coerceAtMost(2)).forEach { add("- ${it.title.take(30)}") }
if (isEmpty()) add("Nothing scheduled")
}
}.getOrElse { listOf("LifeOS") }

private companion object {
val AT = SimpleDateFormat("EEE HH:mm", Locale.getDefault())
val TITLE = androidx.compose.ui.graphics.Color(0xFF9FCBA6)
}
}

/** Receiver the system talks to. */
class NextUpWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = NextUpWidget()
}

@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun calendarDao(): CalendarDao
fun captureDao(): CaptureDao
}
6 changes: 6 additions & 0 deletions app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ import com.lifeos.feature.vault.VaultRoute
import com.lifeos.feature.screentime.ScreenTimeRoute
import com.lifeos.feature.brick.BrickRoute
import com.lifeos.feature.clearsky.ClearSkyRoute
import com.lifeos.feature.signals.SignalsRoute
import com.lifeos.feature.sync.SyncRoute
import com.lifeos.feature.triggers.TriggersRoute
import com.lifeos.feature.pastebin.PastebinRoute

/**
Expand Down Expand Up @@ -194,6 +197,9 @@ fun LifeOsApp(captureRequests: Int = 0, navBarIds: List<String> = emptyList()) {
composable<LifeDestination.Brick> { BrickRoute() }
composable<LifeDestination.Pastebin> { PastebinRoute() }
composable<LifeDestination.ClearSky> { ClearSkyRoute() }
composable<LifeDestination.Triggers> { TriggersRoute() }
composable<LifeDestination.Signals> { SignalsRoute() }
composable<LifeDestination.Sync> { SyncRoute() }
}
}

Expand Down
21 changes: 21 additions & 0 deletions app/src/main/kotlin/com/lifeos/app/ui/screen/HomeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.DocumentScanner
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.GridView
Expand Down Expand Up @@ -224,6 +227,24 @@ fun HomeScreen(
icon = Icons.Filled.ContentPaste,
destination = LifeDestination.Pastebin,
),
AppGridItem(
label = "Triggers",
description = "When this happens, do that",
icon = Icons.Filled.Bolt,
destination = LifeDestination.Triggers,
),
AppGridItem(
label = "Signals",
description = "What you missed, grouped",
icon = Icons.Filled.Notifications,
destination = LifeDestination.Signals,
),
AppGridItem(
label = "Sync",
description = "Encrypted backups to your NAS",
icon = Icons.Filled.CloudUpload,
destination = LifeDestination.Sync,
),
AppGridItem(
label = "Clear Sky Map",
description = "Stargazing forecast for any spot",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ class SettingsViewModel @Inject constructor(
val DEFAULT_HOME_ORDER = listOf(
"Notes", "Logger", "Packages", "Finance", "Scan", "Planner", "Books",
"Routes", "Smart home", "NAS", "Clock", "Focus", "Memex", "Macros", "Evolution",
"Downloader", "Plants", "News", "Brick", "Screen Time", "Pastebin", "Clear Sky Map",
"Downloader", "Plants", "News", "Brick", "Screen Time", "Pastebin", "Clear Sky Map", "Triggers", "Signals", "Sync",
)
}
}
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/res/xml/next_up_widget_info.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="180dp"
android:minHeight="80dp"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />
Loading
Loading