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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ or on your own NAS (Ollama). No third-party cloud, ever.
**Spec:** [`docs/PRODUCTION_PLAN.md`](docs/PRODUCTION_PLAN.md). Every module
and rule traces back to a section (and often a community demand source) there.

## Status — v0.1.0-alpha.17
## Status — v0.1.0-alpha.18

| Area | State |
|---|---|
Expand Down Expand Up @@ -41,7 +41,7 @@ and rule traces back to a section (and often a community demand source) there.
| Vault v2: dedicated screens per item — Proton-Pass-style logins (password generator, on-device TOTP 2FA, custom fields, attachments), Markdown secure texts, zoomable image gallery (downsampled, no OOM); hidden behind a 5-second hold on the Home title | ✅ |
| Notes editor: Word-style Markdown toolbar (bold/italic/heading/list/quote/code/link on the selection), rendered⇄raw toggle + pen; optional readable mirror to /Internal storage/LifeOS/Notes/*.md (all-files access) | ✅ |
| Focus timer: tap the ring for a custom HH:MM:SS time, plus an "Overlay" button that floats the countdown over any app (tap once for a close X that leaves it running); Overwhelm overlay now follows the theme | ✅ |
| Screen Time: mirrors Android digital-wellbeing into LifeOS and keeps it forever (survives Samsung's ~monthly purge) — weekly bars + average, week scrolling, per-app breakdown, unlocks, JSON export | ✅ |
| Screen Time: mirrors Android digital-wellbeing into LifeOS and keeps it forever (survives Samsung's ~monthly purge) — weekly bars + average, week scrolling, tap a day for its own apps/unlocks, JSON export. Totals are derived from the raw RESUMED/PAUSED event stream, not `queryAndAggregateUsageStats` (which reports whole-bucket sums per day) | ✅ |
| Screen Time · Plants (custom photos + care atlas) · News · Downloader on Home; NAS server apps redesigned as an app-store list; Jarvis answers from a live data snapshot (note bodies included) and a Developer Options "Jarvis Debugging" toggle exposes snapshot/output/tool-calls with a copy button | ✅ |
| Brick (§Module Brick): tap-to-block modes — pick blocked apps + optional per-app daily allowances, turn a mode on/off by NFC tag, time window or by hand, strict mode refuses early exits; blocked apps hit a full-screen wall via an accessibility blocker (the only route Android gives a sideloaded app). Blocking rules covered by unit tests | ✅ |
| Deferred post-alpha: Glance home-screen widgets, HA WebSocket live state/zones, Vault unlock UI, first-run onboarding checklist (grants live in Settings → System access), FinTS bank sync | 🔜 |
Expand All @@ -53,7 +53,7 @@ and rule traces back to a section (and often a community demand source) there.
Grab `lifeos-v*.apk` from [Releases](../../releases), then:

```
adb install -r -g lifeos-v0.1.0-alpha.17.apk
adb install -r -g lifeos-v0.1.0-alpha.18.apk
```

or copy to the phone and allow *Install unknown apps*. Android 13+ (minSdk 33).
Expand Down
4 changes: 2 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 = 17
versionName = "0.1.0-alpha.17"
versionCode = 18
versionName = "0.1.0-alpha.18"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ interface BrickDao {
@Query("SELECT * FROM brick_profiles WHERE id = :id")
suspend fun profile(id: Long): BrickProfileEntity?

@Query("SELECT * FROM brick_profiles WHERE nfcTagId = :tagId LIMIT 1")
@Query("SELECT * FROM brick_profiles WHERE UPPER(TRIM(nfcTagId)) = UPPER(TRIM(:tagId)) LIMIT 1")
suspend fun profileByTag(tagId: String): BrickProfileEntity?

@Query("DELETE FROM brick_profiles WHERE id = :id")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,17 @@ interface ScreenTimeDao {

@Query("SELECT date FROM screen_time_days")
suspend fun capturedDates(): List<String>

@Query("SELECT * FROM screen_time_days WHERE date = :date")
suspend fun day(date: String): ScreenTimeDayEntity?

@Query("SELECT * FROM screen_time_apps WHERE date = :date ORDER BY foregroundMs DESC")
suspend fun appsOn(date: String): List<AppUsageEntity>

/** Wipes derived rows so they can be rebuilt (used by the v2 recompute). */
@Query("DELETE FROM screen_time_days")
suspend fun deleteAllDays()

@Query("DELETE FROM screen_time_apps")
suspend fun deleteAllApps()
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ internal class DataStoreSettingsRepository @Inject constructor(
dataStore.edit { prefs -> prefs[KEY_JARVIS_DEBUG] = enabled }
}

override val screenTimeRebuilt: Flow<Boolean> =
dataStore.data.map { prefs -> prefs[KEY_SCREEN_TIME_REBUILT] ?: false }

override suspend fun setScreenTimeRebuilt(done: Boolean) {
dataStore.edit { prefs -> prefs[KEY_SCREEN_TIME_REBUILT] = done }
}

override val publicFolderMirror: Flow<Boolean> =
dataStore.data.map { prefs -> prefs[KEY_PUBLIC_FOLDER_MIRROR] ?: false }

Expand All @@ -116,5 +123,6 @@ internal class DataStoreSettingsRepository @Inject constructor(
val KEY_HOME_ORDER = stringPreferencesKey("home_order")
val KEY_JARVIS_DEBUG = booleanPreferencesKey("jarvis_debug")
val KEY_PUBLIC_FOLDER_MIRROR = booleanPreferencesKey("public_folder_mirror")
val KEY_SCREEN_TIME_REBUILT = booleanPreferencesKey("screen_time_rebuilt_v2")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,12 @@ interface SettingsRepository {
val publicFolderMirror: Flow<Boolean>

suspend fun setPublicFolderMirror(enabled: Boolean)

/**
* Whether screen-time rows have been rebuilt with the event-based
* calculation (the first implementation stored inflated bucket totals).
*/
val screenTimeRebuilt: Flow<Boolean>

suspend fun setScreenTimeRebuilt(done: Boolean)
}
9 changes: 9 additions & 0 deletions feature/brick/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@
<intent-filter>
<action android:name="android.nfc.action.TECH_DISCOVERED" />
</intent-filter>
<!--
Last-resort filter: plain tags whose NDEF mime does not match and
whose tech list is unusual still land here, which is what makes an
arbitrary tag usable as a Brick key.
-->
<intent-filter>
<action android:name="android.nfc.action.TAG_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.nfc.action.TECH_DISCOVERED"
android:resource="@xml/brick_nfc_tech" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.lifeos.feature.brick

import android.app.Activity
import android.nfc.NfcAdapter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -59,6 +58,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.lifeos.core.database.brick.BrickProfileEntity
import com.lifeos.core.designsystem.component.EmptyState
import com.lifeos.feature.brick.nfc.BrickReader
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
Expand Down Expand Up @@ -95,8 +95,14 @@ fun BrickRoute(viewModel: BrickViewModel = hiltViewModel()) {
}
}

// Foreground NFC reader while pairing a tag in the editor.
NfcPairingEffect(enabled = pairing, onTag = viewModel::onTagPaired)
// Reader mode stays on the whole time Brick is open: while pairing a tag it
// captures the id, otherwise a tap flips the matching mode right here.
// (Outside the app, the manifest's NFC filters route taps to BrickNfcActivity.)
BrickReaderEffect(
onUid = { uid ->
if (viewModel.pairingTag.value) viewModel.onTagPaired(uid) else viewModel.onTagTapped(uid)
},
)

if (draft != null) {
ProfileEditor(viewModel = viewModel, snackbarHostState = snackbarHostState)
Expand Down Expand Up @@ -297,7 +303,8 @@ private fun ProfileEditor(viewModel: BrickViewModel, snackbarHostState: Snackbar
}
Text(
if (pairing) "Hold the tag against the back of the phone…"
else "Any NFC tag or card works — one tap flips this mode on and off.",
else "Any NFC tag or card works — one tap flips this mode on and off. " +
"Tap it here any time to test.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Expand Down Expand Up @@ -466,26 +473,28 @@ private fun MinuteField(label: String, minuteOfDay: Int?, onChange: (Int?) -> Un
}
}

/** Reader-mode NFC while the editor is pairing a tag. */
/**
* Keeps NFC reader mode running while the Brick screen is on top, and stops it
* when the screen goes away so the system's normal tag dispatch takes over again.
*/
@Composable
private fun NfcPairingEffect(enabled: Boolean, onTag: (String) -> Unit) {
private fun BrickReaderEffect(onUid: (String) -> Unit) {
val context = LocalContext.current
DisposableEffect(enabled) {
val activity = context as? Activity
val adapter = NfcAdapter.getDefaultAdapter(context)
if (!enabled || activity == null || adapter == null) return@DisposableEffect onDispose {}
val callback = NfcAdapter.ReaderCallback { tag ->
tag.id?.joinToString("") { "%02X".format(it) }?.takeIf { it.isNotEmpty() }?.let(onTag)
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> BrickReader.start(activity) { uid -> onUid(uid); true }
Lifecycle.Event.ON_PAUSE -> BrickReader.stop(activity)
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
BrickReader.stop(activity)
}
adapter.enableReaderMode(
activity,
callback,
NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_NFC_F or NfcAdapter.FLAG_READER_NFC_V or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
null,
)
onDispose { runCatching { adapter.disableReaderMode(activity) } }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,21 @@ class BrickViewModel @Inject constructor(

/** Called by the screen when a tag is read while the editor is pairing. */
fun onTagPaired(tagId: String) {
val normalized = tagId.trim().uppercase()
_pairingTag.value = false
updateDraft { it.copy(nfcTagId = tagId, activator = "NFC", deactivator = "NFC") }
_message.value = "Tag paired"
updateDraft { it.copy(nfcTagId = normalized, activator = "NFC", deactivator = "NFC") }
_message.value = "Tag $normalized paired — remember to Save"
}

/**
* A tag tapped while Brick is open (reader mode, not intent dispatch).
* Flips the matching mode exactly like a tap from outside the app.
*/
fun onTagTapped(tagId: String) {
viewModelScope.launch {
brickRepository.refresh()
_message.value = brickRepository.onTagScanned(tagId)
}
}

fun saveDraft() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,18 +130,25 @@ class BrickRepository @Inject constructor(
return true
}

/** NFC tap: flips the mode bound to [tagId]. Returns a message for the UI. */
suspend fun onTagScanned(tagId: String): String {
/** NFC tap: flips the mode bound to [rawTagId]. Returns a message for the UI. */
suspend fun onTagScanned(rawTagId: String): String {
// Ids are stored uppercase-hex; compare normalized so a tag paired in
// one code path always matches a tap arriving through another.
val tagId = rawTagId.trim().uppercase()
LifeLogger.i(TAG, "Brick tag scanned: $tagId")
val running = _active.value
if (running != null) {
return if (running.profile.nfcTagId == tagId) {
if (stop("NFC")) "\"${running.profile.name}\" unlocked" else "Wrong tag for this mode"
} else {
"\"${running.profile.name}\" stays locked — that's a different tag"
val runningTag = running.profile.nfcTagId?.trim()?.uppercase()
return when {
runningTag == tagId ->
if (stop("NFC")) "\"${running.profile.name}\" unlocked" else "Wrong tag for this mode"
// A different tag may belong to another mode, but only one mode
// runs at a time — say so instead of silently doing nothing.
else -> "\"${running.profile.name}\" is still running; end it with its own tag first"
}
}
val profile = brickDao.profileByTag(tagId)
?: return "Unknown tag. Pair it with a mode in Brick first."
?: return "Tag $tagId is not paired with any mode yet — pair it in Brick"
return if (start(profile.id, "NFC")) "\"${profile.name}\" is now blocking" else "Couldn't start the mode"
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.lifeos.feature.brick.nfc

import android.app.Activity
import android.content.Intent
import android.nfc.NfcAdapter
import android.nfc.Tag

/** Hex UID of a scanned tag, uppercase — the id Brick pairs modes against. */
fun Tag.uid(): String? =
id?.joinToString("") { "%02X".format(it) }?.takeIf { it.isNotEmpty() }

/** Hex UID from an NFC dispatch intent, or null when it carries no tag. */
fun Intent.tagUid(): String? {
@Suppress("DEPRECATION")
val tag = getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG) ?: return null
return tag.uid()
}

/**
* Reader-mode helper (§Module Brick). Foreground reader mode beats the system's
* intent dispatch, so a tap is delivered to whatever screen is open instead of
* bouncing through a new activity — that is what makes in-app taps work.
*/
object BrickReader {

private const val FLAGS = NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_NFC_F or
NfcAdapter.FLAG_READER_NFC_V or
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK

/** Starts reading; returns false when the device has no usable NFC. */
fun start(activity: Activity, onUid: (String) -> Boolean): Boolean {
val adapter = NfcAdapter.getDefaultAdapter(activity) ?: return false
if (!adapter.isEnabled) return false
runCatching {
adapter.enableReaderMode(
activity,
{ tag -> tag.uid()?.let { onUid(it) } },
FLAGS,
null,
)
}.onFailure { return false }
return true
}

fun stop(activity: Activity) {
val adapter = NfcAdapter.getDefaultAdapter(activity) ?: return
runCatching { adapter.disableReaderMode(activity) }
}

fun isAvailable(activity: Activity): Boolean =
NfcAdapter.getDefaultAdapter(activity)?.isEnabled == true
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package com.lifeos.feature.brick.nfc

import android.content.Intent
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
Expand Down Expand Up @@ -67,7 +65,7 @@ class BrickNfcActivity : FragmentActivity() {
}

private fun handle(intent: Intent?) {
val tagId = intent?.tagId()
val tagId = intent?.tagUid()
if (tagId == null) {
finish()
return
Expand All @@ -81,9 +79,3 @@ class BrickNfcActivity : FragmentActivity() {
}
}

/** Hex id of the scanned tag, or null when the intent carries no tag. */
internal fun Intent.tagId(): String? {
@Suppress("DEPRECATION")
val tag = getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG) ?: return null
return tag.id?.joinToString("") { "%02X".format(it) }?.takeIf { it.isNotEmpty() }
}
1 change: 1 addition & 0 deletions feature/screentime/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dependencies {
implementation(projects.core.common)
implementation(projects.core.designsystem)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.ui)

implementation(libs.androidx.lifecycle.viewmodel.compose)
Expand Down
Loading
Loading