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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ and rule traces back to a section (and often a community demand source) there.
| Navigation bar: any module can be a bottom-bar tab, not just Calendar/Jarvis/Inbox/Tasks | Done |
| Clock timer: tap an h/m/s wheel to type the value on a number keyboard; a full number hops to the field on the right automatically | Done |
| Clear Sky Map module: full clearoutside.com forecast for any spot (search, coordinates or device location) - hourly good/OK/bad ratings, total/low/medium/high cloud, visibility, fog, precipitation, wind, temperature, dew point, humidity, pressure, ozone, sun/moon ephemeris, dark windows and estimated sky quality/Bortle class. No API key needed; place lookup via OpenStreetMap Nominatim | Done |
| Navigation bar settings list every module (was still showing only the original four), with a searchable picker; the default bar stays Home + Calendar/Tasks/Inbox/Jarvis | Done |
| Downloader resolves session-bound player links (ThisVid and the rest of the kt_player family) by letting the page's own player run in an offscreen WebView and recording the media request, then downloads it with that session's Referer, Cookie and User-Agent; teaser/sprite/ad URLs are filtered out | Done |
| 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 |
| 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
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 = 19
versionName = "0.1.0-alpha.19"
versionCode = 20
versionName = "0.1.0-alpha.20"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

Expand Down
9 changes: 8 additions & 1 deletion app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,14 @@ fun LifeOsApp(captureRequests: Int = 0, navBarIds: List<String> = emptyList()) {
// tabs in their chosen ORDER (the stored list is ordered).
val barItems = remember(navBarIds) {
if (navBarIds.isEmpty()) {
TopLevelDestination.entries.toList()
// Default four tabs; Settings can swap in any other module.
listOf(
TopLevelDestination.HOME,
TopLevelDestination.CALENDAR,
TopLevelDestination.TASKS,
TopLevelDestination.INBOX,
TopLevelDestination.ASSISTANT,
)
} else {
listOf(TopLevelDestination.HOME) +
navBarIds.mapNotNull { id ->
Expand Down
56 changes: 37 additions & 19 deletions app/src/main/kotlin/com/lifeos/app/ui/settings/SettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.lifeos.core.designsystem.component.SectionHeader
import com.lifeos.core.designsystem.theme.PALETTE_DYNAMIC
import com.lifeos.core.designsystem.theme.ThemePalettes
import com.lifeos.core.ui.navigation.TopLevelDestination

/** Central settings (§8.4 onboarding grants + endpoints in one place). */
@OptIn(ExperimentalMaterial3Api::class)
Expand Down Expand Up @@ -130,13 +131,10 @@ fun SettingsRoute(
)

SectionHeader(title = "Navigation bar")
val navLabels = mapOf(
"CALENDAR" to "Calendar",
"TASKS" to "Tasks",
"INBOX" to "Inbox",
"ASSISTANT" to "Jarvis",
)
// Enabled tabs first (orderable), then disabled ones to re-enable.
// Every module can be a tab; Home stays pinned as the first slot.
val navLabels = TopLevelDestination.entries
.filter { it != TopLevelDestination.HOME }
.associate { it.name to it.label }
uiState.navBarItems.forEachIndexed { index, id ->
ReorderRow(
label = navLabels[id] ?: id,
Expand All @@ -148,24 +146,44 @@ fun SettingsRoute(
onToggle = { viewModel.onEvent(SettingsUiEvent.ToggleNavItem(id)) },
)
}
navLabels.keys.filter { it !in uiState.navBarItems }.forEach { id ->
ReorderRow(
label = navLabels[id] ?: id,
enabled = false,
canMoveUp = false,
canMoveDown = false,
onMoveUp = {},
onMoveDown = {},
onToggle = { viewModel.onEvent(SettingsUiEvent.ToggleNavItem(id)) },
)
}
Text(
"Toggle tabs and order them with the arrows Home stays pinned first. " +
"Toggle tabs and order them with the arrows - Home stays pinned first. " +
"Everything stays reachable from the Home grid.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)

var navSearch by remember { mutableStateOf("") }
var showNavPicker by remember { mutableStateOf(false) }
Button(onClick = { showNavPicker = !showNavPicker }) {
Text(if (showNavPicker) "Done adding tabs" else "Add a module as a tab")
}
if (showNavPicker) {
OutlinedTextField(
value = navSearch,
onValueChange = { navSearch = it },
label = { Text("Search modules") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
navLabels.entries
.filter { (id, label) ->
id !in uiState.navBarItems &&
(navSearch.isBlank() || label.contains(navSearch.trim(), ignoreCase = true))
}
.forEach { (id, label) ->
ReorderRow(
label = label,
enabled = false,
canMoveUp = false,
canMoveDown = false,
onMoveUp = {},
onMoveDown = {},
onToggle = { viewModel.onEvent(SettingsUiEvent.ToggleNavItem(id)) },
)
}
}

SectionHeader(title = "AI")
OutlinedTextField(
value = uiState.ollamaBaseUrl,
Expand Down
12 changes: 12 additions & 0 deletions feature/brick/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,25 @@
android:noHistory="true"
android:taskAffinity="com.lifeos.brick.nfc"
android:theme="@style/Theme.LifeOs">
<!--
Preferred route: tags Brick programmed carry this MIME record plus
an Android Application Record, so a tap reaches LifeOS even with
the app closed and no chooser appears.
-->
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/vnd.lifeos.brick" />
</intent-filter>
<!-- Tags written by other tools (plain text records) still work. -->
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.TECH_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!--
Last-resort filter: plain tags whose NDEF mime does not match and
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.lifeos.feature.brick

import android.app.Activity
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -59,6 +60,8 @@ 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 com.lifeos.feature.brick.nfc.BrickTagWriter
import com.lifeos.feature.brick.nfc.uid
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
Expand Down Expand Up @@ -98,9 +101,20 @@ fun BrickRoute(viewModel: BrickViewModel = hiltViewModel()) {
// 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.)
val appContext = LocalContext.current
val nfcReady = remember(appContext) {
android.nfc.NfcAdapter.getDefaultAdapter(appContext)?.isEnabled != false
}
BrickReaderEffect(
onUid = { uid ->
if (viewModel.pairingTag.value) viewModel.onTagPaired(uid) else viewModel.onTagTapped(uid)
onTag = { tag ->
val uid = tag.uid() ?: return@BrickReaderEffect
if (viewModel.pairingTag.value) {
// Pairing also programs the tag, which is what makes taps work
// with LifeOS closed.
viewModel.onTagPaired(uid, BrickTagWriter.write(tag, uid, appContext.packageName))
} else {
viewModel.onTagTapped(uid)
}
},
)

Expand Down Expand Up @@ -142,6 +156,32 @@ fun BrickRoute(viewModel: BrickViewModel = hiltViewModel()) {
}
}

if (!nfcReady) {
item {
Card {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("NFC is off", style = MaterialTheme.typography.titleMedium)
Text(
"Turn NFC on to pair a tag and to flip modes by tapping it. Android only " +
"dispatches tags while the screen is unlocked, so a tap wakes nothing " +
"from a locked phone.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = {
runCatching {
appContext.startActivity(
Intent(android.provider.Settings.ACTION_NFC_SETTINGS),
)
}
},
) { Text("Open NFC settings") }
}
}
}
}

active?.let { mode ->
item {
Card {
Expand Down Expand Up @@ -233,6 +273,7 @@ private fun ProfileEditor(viewModel: BrickViewModel, snackbarHostState: Snackbar
val apps by viewModel.apps.collectAsState()
val pairing by viewModel.pairingTag.collectAsState()
val current = draft ?: return
val nfcSettingsContext = LocalContext.current
var query by remember { mutableStateOf("") }
var showAllApps by remember { mutableStateOf(false) }

Expand Down Expand Up @@ -302,14 +343,30 @@ 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. " +
"Tap it here any time to test.",
if (pairing) {
"Hold the tag against the back of the phone. LifeOS also writes a " +
"small record onto it so taps work with the app closed."
} else {
"Any writable NFC tag works. Pairing programs the tag, so a tap flips " +
"this mode from anywhere - no need to open LifeOS first. The screen " +
"does have to be unlocked; Android never dispatches tags while locked."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = { viewModel.startTagPairing() }, enabled = !pairing) {
Text(if (current.nfcTagId == null) "Pair tag" else "Pair a different tag")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { viewModel.startTagPairing() }, enabled = !pairing) {
Text(if (current.nfcTagId == null) "Pair tag" else "Pair a different tag")
}
OutlinedButton(
onClick = {
runCatching {
nfcSettingsContext.startActivity(
Intent(android.provider.Settings.ACTION_NFC_SETTINGS),
)
}
},
) { Text("NFC settings") }
}
}
}
Expand Down Expand Up @@ -478,14 +535,14 @@ private fun MinuteField(label: String, minuteOfDay: Int?, onChange: (Int?) -> Un
* when the screen goes away so the system's normal tag dispatch takes over again.
*/
@Composable
private fun BrickReaderEffect(onUid: (String) -> Unit) {
private fun BrickReaderEffect(onTag: (android.nfc.Tag) -> Unit) {
val context = LocalContext.current
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_RESUME -> BrickReader.startForTag(activity) { tag -> onTag(tag); true }
Lifecycle.Event.ON_PAUSE -> BrickReader.stop(activity)
else -> Unit
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,23 @@ class BrickViewModel @Inject constructor(

fun startTagPairing() { _pairingTag.value = true }

/** Called by the screen when a tag is read while the editor is pairing. */
fun onTagPaired(tagId: String) {
/**
* Called by the screen when a tag is read while the editor is pairing.
*
* @param programmed whether the Brick record could be written onto the tag.
* Without it, taps only register while LifeOS is open, so the difference
* is worth telling the user about.
*/
fun onTagPaired(tagId: String, programmed: Result<Unit>) {
val normalized = tagId.trim().uppercase()
_pairingTag.value = false
updateDraft { it.copy(nfcTagId = normalized, activator = "NFC", deactivator = "NFC") }
_message.value = "Tag $normalized paired — remember to Save"
_message.value = if (programmed.isSuccess) {
"Tag $normalized paired and programmed - taps work with LifeOS closed. Remember to Save"
} else {
"Tag $normalized paired by serial - taps only register with LifeOS open " +
"(${programmed.exceptionOrNull()?.message ?: "could not write to the tag"}). Remember to Save"
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,25 @@ package com.lifeos.feature.brick.nfc

import android.app.Activity
import android.content.Intent
import android.nfc.NdefMessage
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. */
/**
* Hex UID from an NFC dispatch intent. Prefers the id Brick wrote into the
* tag's NDEF record (that survives tags whose serial the system hides) and
* falls back to the raw tag serial.
*/
fun Intent.tagUid(): String? {
@Suppress("DEPRECATION")
val messages = getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)
?.filterIsInstance<NdefMessage>()
.orEmpty()
messages.firstNotNullOfOrNull { BrickTagWriter.brickPayload(it) }?.let { return it }
@Suppress("DEPRECATION")
val tag = getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG) ?: return null
return tag.uid()
Expand All @@ -23,24 +33,26 @@ fun Intent.tagUid(): String? {
*/
object BrickReader {

private const val FLAGS = NfcAdapter.FLAG_READER_NFC_A or
private const val TECHS = 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
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS

/** Starts reading; returns false when the device has no usable NFC. */
fun start(activity: Activity, onUid: (String) -> Boolean): Boolean {
fun start(activity: Activity, onUid: (String) -> Boolean): Boolean =
startForTag(activity) { tag -> tag.uid()?.let { onUid(it) } ?: false }

/**
* Reader mode that hands over the whole tag, so pairing can also program it.
* NDEF discovery stays on here (unlike plain id reading) because writing
* needs the Ndef/NdefFormatable technology to be available.
*/
fun startForTag(activity: Activity, onTag: (Tag) -> 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,
)
adapter.enableReaderMode(activity, { tag -> onTag(tag) }, TECHS, null)
}.onFailure { return false }
return true
}
Expand Down
Loading
Loading