diff --git a/README.md b/README.md index 7954c4b..7b61060 100644 --- a/README.md +++ b/README.md @@ -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 | |---|---| @@ -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 | 🔜 | @@ -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). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7ff31ac..29fbd62 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/core/database/src/main/kotlin/com/lifeos/core/database/brick/BrickEntities.kt b/core/database/src/main/kotlin/com/lifeos/core/database/brick/BrickEntities.kt index fa7e738..c407827 100644 --- a/core/database/src/main/kotlin/com/lifeos/core/database/brick/BrickEntities.kt +++ b/core/database/src/main/kotlin/com/lifeos/core/database/brick/BrickEntities.kt @@ -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") diff --git a/core/database/src/main/kotlin/com/lifeos/core/database/screentime/ScreenTimeEntities.kt b/core/database/src/main/kotlin/com/lifeos/core/database/screentime/ScreenTimeEntities.kt index a486efd..0ea6cd4 100644 --- a/core/database/src/main/kotlin/com/lifeos/core/database/screentime/ScreenTimeEntities.kt +++ b/core/database/src/main/kotlin/com/lifeos/core/database/screentime/ScreenTimeEntities.kt @@ -53,4 +53,17 @@ interface ScreenTimeDao { @Query("SELECT date FROM screen_time_days") suspend fun capturedDates(): List + + @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 + + /** 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() } 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 01aaf44..af33a8e 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 @@ -97,6 +97,13 @@ internal class DataStoreSettingsRepository @Inject constructor( dataStore.edit { prefs -> prefs[KEY_JARVIS_DEBUG] = enabled } } + override val screenTimeRebuilt: Flow = + 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 = dataStore.data.map { prefs -> prefs[KEY_PUBLIC_FOLDER_MIRROR] ?: false } @@ -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") } } 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 d580569..e1b49cc 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 @@ -58,4 +58,12 @@ interface SettingsRepository { val publicFolderMirror: Flow 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 + + suspend fun setScreenTimeRebuilt(done: Boolean) } diff --git a/feature/brick/src/main/AndroidManifest.xml b/feature/brick/src/main/AndroidManifest.xml index 406683f..d613fc8 100644 --- a/feature/brick/src/main/AndroidManifest.xml +++ b/feature/brick/src/main/AndroidManifest.xml @@ -57,6 +57,15 @@ + + + + + diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickScreen.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickScreen.kt index 7a20333..7279134 100644 --- a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickScreen.kt +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickScreen.kt @@ -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 @@ -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 @@ -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) @@ -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, ) @@ -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) } } } } diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickViewModel.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickViewModel.kt index 64aac57..db3acb9 100644 --- a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickViewModel.kt +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/BrickViewModel.kt @@ -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() { diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickRepository.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickRepository.kt index ad50d62..7cac7c1 100644 --- a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickRepository.kt +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/data/BrickRepository.kt @@ -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" } diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/nfc/BrickNfc.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/nfc/BrickNfc.kt new file mode 100644 index 0000000..2c20ae7 --- /dev/null +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/nfc/BrickNfc.kt @@ -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(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 +} diff --git a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/nfc/BrickNfcActivity.kt b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/nfc/BrickNfcActivity.kt index 8d7238c..4dba336 100644 --- a/feature/brick/src/main/kotlin/com/lifeos/feature/brick/nfc/BrickNfcActivity.kt +++ b/feature/brick/src/main/kotlin/com/lifeos/feature/brick/nfc/BrickNfcActivity.kt @@ -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 @@ -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 @@ -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(NfcAdapter.EXTRA_TAG) ?: return null - return tag.id?.joinToString("") { "%02X".format(it) }?.takeIf { it.isNotEmpty() } -} diff --git a/feature/screentime/build.gradle.kts b/feature/screentime/build.gradle.kts index 4b81324..4fed39b 100644 --- a/feature/screentime/build.gradle.kts +++ b/feature/screentime/build.gradle.kts @@ -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) diff --git a/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/ScreenTimeScreen.kt b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/ScreenTimeScreen.kt index 83f945f..c3ad7c3 100644 --- a/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/ScreenTimeScreen.kt +++ b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/ScreenTimeScreen.kt @@ -4,6 +4,7 @@ import android.content.ContentValues import android.content.Intent import android.provider.MediaStore import android.provider.Settings +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -59,6 +60,10 @@ fun ScreenTimeRoute(viewModel: ScreenTimeViewModel = hiltViewModel()) { LaunchedEffect(Unit) { viewModel.refresh() } + state.dayDetail?.let { detail -> + DayDetailSheet(detail = detail, onDismiss = viewModel::closeDay) + } + Scaffold( topBar = { TopAppBar( @@ -127,7 +132,7 @@ fun ScreenTimeRoute(viewModel: ScreenTimeViewModel = hiltViewModel()) { StatCard("Week total", formatDuration(state.weekTotalMs), Modifier.weight(1f)) } } - item { WeekBars(state.days) } + item { WeekBars(state.days) { date -> viewModel.openDay(date) } } item { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { StatCard("Unlocks", state.days.sumOf { it.unlocks }.toString(), Modifier.weight(1f)) @@ -155,7 +160,7 @@ fun ScreenTimeRoute(viewModel: ScreenTimeViewModel = hiltViewModel()) { } item { Text( - "${state.totalDaysStored} day(s) stored permanently in LifeOS.", + "Tap a bar for that day's apps and unlocks · ${state.totalDaysStored} day(s) stored permanently in LifeOS.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -164,6 +169,45 @@ fun ScreenTimeRoute(viewModel: ScreenTimeViewModel = hiltViewModel()) { } } +/** Per-day drill-down: total, unlocks and the day's own app ranking. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DayDetailSheet(detail: DayDetail, onDismiss: () -> Unit) { + androidx.compose.material3.ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(detail.label, style = MaterialTheme.typography.titleLarge) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + StatCard("Screen time", formatDuration(detail.totalMs), Modifier.weight(1f)) + StatCard("Unlocks", detail.unlocks.toString(), Modifier.weight(1f)) + } + if (detail.apps.isEmpty()) { + Text( + "No app usage recorded for this day.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text("Apps used", style = MaterialTheme.typography.titleMedium) + detail.apps.take(20).forEach { app -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(app.label, maxLines = 1, modifier = Modifier.weight(1f)) + Text(formatDuration(app.ms), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + } +} + @Composable private fun StatCard(label: String, value: String, modifier: Modifier = Modifier) { Card(modifier = modifier) { @@ -175,7 +219,7 @@ private fun StatCard(label: String, value: String, modifier: Modifier = Modifier } @Composable -private fun WeekBars(days: List) { +private fun WeekBars(days: List, onDayClick: (String) -> Unit) { val max = (days.maxOfOrNull { it.totalMs } ?: 0L).coerceAtLeast(1L) Card { Row( @@ -185,7 +229,9 @@ private fun WeekBars(days: List) { ) { days.forEach { day -> Column( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .clickable { onDayClick(day.date) }, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Bottom, ) { 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 c04ae62..5a4b43d 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 @@ -5,7 +5,9 @@ import androidx.lifecycle.viewModelScope 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.ScreenTimeCollector +import kotlinx.coroutines.flow.first import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -23,6 +25,15 @@ data class DayBar(val date: String, val label: String, val totalMs: Long, val un data class AppLine(val label: String, val packageName: String, val ms: Long) +/** One day drilled into: its own apps, unlocks and total. */ +data class DayDetail( + val date: String, + val label: String, + val totalMs: Long, + val unlocks: Int, + val apps: List, +) + data class ScreenTimeUiState( val hasPermission: Boolean = false, val loading: Boolean = false, @@ -34,12 +45,15 @@ data class ScreenTimeUiState( val weekTotalMs: Long = 0, val topApps: List = emptyList(), val totalDaysStored: Int = 0, + /** Non-null while a single day is open. */ + val dayDetail: DayDetail? = null, ) @HiltViewModel class ScreenTimeViewModel @Inject constructor( private val dao: ScreenTimeDao, private val collector: ScreenTimeCollector, + private val settingsRepository: SettingsRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(ScreenTimeUiState()) @@ -48,16 +62,52 @@ class ScreenTimeViewModel @Inject constructor( private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) private val dayLabelFormat = SimpleDateFormat("EEE", Locale.getDefault()) private val rangeFormat = SimpleDateFormat("d MMM", Locale.getDefault()) + private val dayTitleFormat = SimpleDateFormat("EEEE, d MMM", Locale.getDefault()) fun refresh() { viewModelScope.launch { _uiState.value = _uiState.value.copy(hasPermission = collector.hasPermission(), loading = true) - if (collector.hasPermission()) collector.sync() + if (collector.hasPermission()) { + // The first release derived totals from queryAndAggregateUsageStats, + // which reports whole-bucket sums per day (the "191h/day" bug). + // Drop those rows once and rebuild from the event stream. + val rebuilt = settingsRepository.screenTimeRebuilt.first() + if (!rebuilt) { + dao.deleteAllDays() + dao.deleteAllApps() + collector.sync(force = true) + settingsRepository.setScreenTimeRebuilt(true) + } else { + collector.sync() + } + } loadWeek(_uiState.value.weekOffset) _uiState.value = _uiState.value.copy(loading = false) } } + /** Opens the per-day breakdown for [date] (yyyy-MM-dd). */ + fun openDay(date: String) { + viewModelScope.launch { + val day = dao.day(date) + val apps = dao.appsOn(date).map { AppLine(it.label, it.packageName, it.foregroundMs) } + val parsed = runCatching { dateFormat.parse(date) }.getOrNull() + _uiState.value = _uiState.value.copy( + dayDetail = DayDetail( + date = date, + label = parsed?.let { dayTitleFormat.format(it) } ?: date, + totalMs = day?.totalForegroundMs ?: 0L, + unlocks = day?.unlocks ?: 0, + apps = apps, + ), + ) + } + } + + fun closeDay() { + _uiState.value = _uiState.value.copy(dayDetail = null) + } + fun previousWeek() { loadWeekAsync(_uiState.value.weekOffset + 1) } fun nextWeek() { if (_uiState.value.weekOffset > 0) loadWeekAsync(_uiState.value.weekOffset - 1) } diff --git a/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/data/ScreenTimeCollector.kt b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/data/ScreenTimeCollector.kt index d8b174e..6a733e2 100644 --- a/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/data/ScreenTimeCollector.kt +++ b/feature/screentime/src/main/kotlin/com/lifeos/feature/screentime/data/ScreenTimeCollector.kt @@ -18,9 +18,15 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Harvests Android's UsageStats into LifeOS's own database (§Module Screen - * Time). Samsung/Android purge raw usage after ~a month; by mirroring each day - * into Room the moment we can read it, LifeOS keeps the history forever. + * Harvests Android's usage data into LifeOS's own database (§Module Screen + * Time). Samsung/Android purge raw usage after ~a month; mirroring each day + * into Room keeps the history forever. + * + * Foreground time is derived from the raw event stream (RESUMED → PAUSED/STOPPED + * pairs), NOT from `queryAndAggregateUsageStats`: that API returns each app's + * total for whatever interval bucket the system picked (often weekly/monthly), + * so asking it for a single day reports the same bloated total for every day — + * which is exactly the "191h per day" nonsense it produced before. */ @Singleton class ScreenTimeCollector @Inject constructor( @@ -41,57 +47,107 @@ class ScreenTimeCollector @Inject constructor( } /** - * Pulls the last [days] days (default 45, wider than Samsung keeps) and - * upserts every day that has data. Already-stored days are refreshed so a - * partial "today" fills in, but historical rows are never dropped. + * Rebuilds the last [days] days from the event stream. Stored days older + * than yesterday are skipped (they can no longer change) unless [force] is + * set, which re-derives everything — used once to replace the bad totals + * written by the old aggregate-based implementation. */ - suspend fun sync(days: Int = 45) = withContext(Dispatchers.IO) { + suspend fun sync(days: Int = 45, force: Boolean = false) = withContext(Dispatchers.IO) { if (!hasPermission()) return@withContext val usageManager = context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager val packageManager = context.packageManager - // Stored days never change again, so only fetch what is missing — plus - // today and yesterday, which are still accumulating. - val alreadyStored = dao.capturedDates().toSet() + val alreadyStored = if (force) emptySet() else dao.capturedDates().toSet() for (offset in 0 until days) { val bounds = dayBounds(offset) - val dayStart = bounds.start - val dayEnd = bounds.end - val dateKey = bounds.key - if (offset > 1 && dateKey in alreadyStored) continue - val stats = usageManager.queryAndAggregateUsageStats(dayStart, dayEnd) - if (stats.isEmpty()) continue - - val apps = stats.values - .filter { it.totalTimeInForeground > 0 } - .map { usage -> + if (offset > 1 && bounds.key in alreadyStored) continue + if (bounds.end <= bounds.start) continue + + val day = deriveDay(usageManager, bounds) ?: continue + val apps = day.foregroundMsByPackage + .filter { it.value > 0 } + .map { (packageName, ms) -> val label = runCatching { packageManager.getApplicationLabel( - packageManager.getApplicationInfo(usage.packageName, 0), + packageManager.getApplicationInfo(packageName, 0), ).toString() - }.getOrDefault(usage.packageName) - AppUsageEntity(dateKey, usage.packageName, label, usage.totalTimeInForeground) + }.getOrDefault(packageName) + AppUsageEntity(bounds.key, packageName, label, ms) } val total = apps.sumOf { it.foregroundMs } - if (total == 0L) continue + if (total == 0L && day.unlocks == 0) continue - val (unlocks, notifications) = countEvents(usageManager, dayStart, dayEnd) - dao.upsertDay(ScreenTimeDayEntity(dateKey, total, unlocks, notifications, System.currentTimeMillis())) - dao.upsertApps(apps) + dao.upsertDay( + ScreenTimeDayEntity( + date = bounds.key, + totalForegroundMs = total, + unlocks = day.unlocks, + notifications = day.notifications, + capturedAt = System.currentTimeMillis(), + ), + ) + if (apps.isNotEmpty()) dao.upsertApps(apps) } } - private fun countEvents(manager: UsageStatsManager, start: Long, end: Long): Pair { - // Unlocks = keyguard-hidden events. Notification counts have no public - // UsageEvents API, so they stay 0 (kept in the schema for a future source). + private data class DayUsage( + val foregroundMsByPackage: Map, + val unlocks: Int, + val notifications: Int, + ) + + /** + * Walks one day's events and sums each app's time on screen. A session runs + * from ACTIVITY_RESUMED to the next PAUSED/STOPPED for that package (or to + * screen-off / end of day, so a night with an app left open can't count + * hours it wasn't visible for). + */ + private fun deriveDay(manager: UsageStatsManager, bounds: DayBounds): DayUsage? { + val events = manager.queryEvents(bounds.start, bounds.end) + if (events == null) return null + + val totals = mutableMapOf() + val resumedAt = mutableMapOf() var unlocks = 0 - val events = manager.queryEvents(start, end) + var notifications = 0 + var sawAnyEvent = false val event = UsageEvents.Event() + + fun close(packageName: String, until: Long) { + val start = resumedAt.remove(packageName) ?: return + val delta = until - start + if (delta > 0) totals[packageName] = (totals[packageName] ?: 0L) + delta + } + while (events.hasNextEvent()) { events.getNextEvent(event) - if (event.eventType == UsageEvents.Event.KEYGUARD_HIDDEN) unlocks++ + sawAnyEvent = true + val packageName = event.packageName ?: continue + val stamp = event.timeStamp.coerceIn(bounds.start, bounds.end) + when (event.eventType) { + UsageEvents.Event.ACTIVITY_RESUMED -> { + // A resume without a matching pause replaces the old mark. + resumedAt[packageName] = stamp + } + UsageEvents.Event.ACTIVITY_PAUSED, + UsageEvents.Event.ACTIVITY_STOPPED, + -> close(packageName, stamp) + UsageEvents.Event.KEYGUARD_HIDDEN -> unlocks++ + // Screen off ends every open session — nothing is on screen now. + UsageEvents.Event.SCREEN_NON_INTERACTIVE, + UsageEvents.Event.KEYGUARD_SHOWN, + -> resumedAt.keys.toList().forEach { close(it, stamp) } + } } - return unlocks to 0 + if (!sawAnyEvent) return null + // Anything still open at the cutoff counts up to the cutoff only. + resumedAt.keys.toList().forEach { close(it, bounds.end) } + + return DayUsage( + foregroundMsByPackage = totals, + unlocks = unlocks, + notifications = notifications, + ) } private data class DayBounds(val start: Long, val end: Long, val key: String)