From 0675978af021329c73f75a5d330d87e807471238 Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Tue, 13 Oct 2026 11:45:46 -0700 Subject: [PATCH 01/12] feat(island): add navigation plugin and settings to island --- .../data/repository/SettingsRepository.kt | 22 +++ .../model/NavigationNotificationData.kt | 73 ++++++++ .../domain/registry/FeatureRegistry.kt | 5 + .../essentials/island/model/IslandModels.kt | 1 + .../island/model/IslandPriorityEntries.kt | 1 + .../plugins/navigation/NavigationExpanded.kt | 93 +++++++++ .../plugins/navigation/NavigationPlugin.kt | 177 ++++++++++++++++++ .../island/service/IslandCoordinator.kt | 2 + .../services/NotificationListener.kt | 86 ++++++++- .../ui/features/display/IslandSettingsUI.kt | 37 +++- .../IslandNavigationOptionsBottomSheet.kt | 132 +++++++++++++ .../essentials/viewmodels/MainViewModel.kt | 7 + app/src/main/res/values/strings.xml | 14 ++ .../essentials/domain/NavigationParserTest.kt | 67 +++++++ 14 files changed, 708 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/domain/model/NavigationNotificationData.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationExpanded.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationPlugin.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNavigationOptionsBottomSheet.kt create mode 100644 app/src/test/java/com/sameerasw/essentials/domain/NavigationParserTest.kt diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 507ae9618..5898a80de 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -610,6 +610,13 @@ class SettingsRepository( const val KEY_ISLAND_SHOW_TRAVEL = "island_show_travel" const val KEY_ISLAND_SHOW_CAFFEINATE = "island_show_caffeinate" const val KEY_ISLAND_SHOW_DEVICES = "island_show_devices" + const val KEY_ISLAND_SHOW_NAVIGATION = "island_show_navigation" + const val KEY_ISLAND_NAVIGATION_ON_NEW = "island_navigation_on_new" + const val KEY_ISLAND_NAVIGATION_PEEK_AFTER_EXPAND = "island_navigation_peek_after_expand" + const val ISLAND_NAVIGATION_ON_NEW_EXPAND = "expand" + const val ISLAND_NAVIGATION_ON_NEW_PEEK = "peek" + const val ISLAND_NAVIGATION_ON_NEW_OFF = "off" + const val KEY_ISLAND_NAVIGATION_EXPAND_DISTANCE = "island_navigation_expand_distance" const val KEY_ISLAND_BRIEF_ENABLED = "island_brief_enabled" const val KEY_ISLAND_BRIEF_SHOW_ALARM = "island_brief_show_alarm" const val KEY_ISLAND_BRIEF_TWO_LINE_HEADER = "island_brief_two_line_header" @@ -4051,6 +4058,21 @@ class SettingsRepository( fun isIslandShowDevicesEnabled(): Boolean = getBoolean(KEY_ISLAND_SHOW_DEVICES, true) fun setIslandShowDevicesEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_SHOW_DEVICES, enabled) + fun isIslandShowNavigationEnabled(): Boolean = getBoolean(KEY_ISLAND_SHOW_NAVIGATION, true) + fun setIslandShowNavigationEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_SHOW_NAVIGATION, enabled) + + // What the island does when a new instruction arrives: expand, peek or off + fun getIslandNavigationOnNew(): String = + getString(KEY_ISLAND_NAVIGATION_ON_NEW, ISLAND_NAVIGATION_ON_NEW_PEEK) ?: ISLAND_NAVIGATION_ON_NEW_PEEK + fun setIslandNavigationOnNew(mode: String) = putString(KEY_ISLAND_NAVIGATION_ON_NEW, mode) + + fun isIslandNavigationPeekAfterExpandEnabled(): Boolean = getBoolean(KEY_ISLAND_NAVIGATION_PEEK_AFTER_EXPAND, true) + fun setIslandNavigationPeekAfterExpandEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_NAVIGATION_PEEK_AFTER_EXPAND, enabled) + + // Meters before a turn at which the island expands; 0 = off + fun getIslandNavigationExpandDistance(): Int = getInt(KEY_ISLAND_NAVIGATION_EXPAND_DISTANCE, 200) + fun setIslandNavigationExpandDistance(meters: Int) = putInt(KEY_ISLAND_NAVIGATION_EXPAND_DISTANCE, meters) + fun isIslandBriefEnabled(): Boolean = getBoolean(KEY_ISLAND_BRIEF_ENABLED, false) fun setIslandBriefEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_BRIEF_ENABLED, enabled) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/NavigationNotificationData.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/NavigationNotificationData.kt new file mode 100644 index 000000000..1916b2592 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/NavigationNotificationData.kt @@ -0,0 +1,73 @@ +package com.sameerasw.essentials.domain.model + +import android.app.PendingIntent +import android.graphics.Bitmap + +data class NavigationNotificationData( + val key: String, + val packageName: String, + val instruction: String?, + val distance: String?, + val eta: String?, + // Turn arrow, usually the notification's large icon. + val maneuver: Bitmap?, + val contentIntent: PendingIntent?, + val exitIntent: PendingIntent?, + val postTime: Long, +) + +object NavigationParser { + data class Parsed(val instruction: String?, val distance: String?, val eta: String?) + + private val distanceRegex = Regex("""\d[\d.,]*\s?(km|m|mi|ft|yd)\b""", RegexOption.IGNORE_CASE) + private val exitRegex = Regex( + """\b(exit|stop|end|close|quit|salir|terminar|detener|finalizar|beenden|quitter|arrêter|sair|encerrar)\b""", + RegexOption.IGNORE_CASE, + ) + + fun isDistance(s: String?) = s != null && s.length <= 24 && distanceRegex.containsMatchIn(s) + + // Apps put the distance in either the title or the text, so take whichever looks like one. + // `chip` is the Live Update status-bar text, which navigation apps fill with the distance. + fun parse(title: String?, text: String?, subText: String?, chip: String?): Parsed { + val titleIsDistance = isDistance(title) + val textIsDistance = !titleIsDistance && isDistance(text) + val instruction = when { + titleIsDistance -> text + textIsDistance -> title + else -> title ?: text + } + val leftover = if (!titleIsDistance && !textIsDistance && title != null) text else null + val distance = chip?.takeIf { it.isNotBlank() } ?: when { + titleIsDistance -> title + textIsDistance -> text + else -> null + } + return Parsed( + instruction = instruction?.takeIf { it.isNotBlank() }, + distance = distance, + eta = (subText ?: leftover)?.takeIf { it.isNotBlank() }, + ) + } + + private val meterRegex = Regex("""(\d{1,3}(?:,\d{3})+|\d+(?:[.,]\d+)?)\s?(km|m|mi|ft|yd)\b""", RegexOption.IGNORE_CASE) + private val unitMeters = mapOf("km" to 1000.0, "m" to 1.0, "mi" to 1609.344, "ft" to 0.3048, "yd" to 0.9144) + + // "1,200 ft" uses a thousands separator; "1,2 km" uses a decimal comma. + fun meters(distance: String?): Double? { + val match = meterRegex.find(distance ?: return null) ?: return null + val (number, unit) = match.destructured + val value = if (Regex("""\d{1,3}(,\d{3})+""").matches(number)) number.replace(",", "") else number.replace(',', '.') + return value.toDoubleOrNull()?.times(unitMeters.getValue(unit.lowercase())) + } + + // ponytail: matches the button label; an app with several unlabeled-looking actions gets no Exit button + fun exitActionIndex(titles: List): Int { + val match = titles.indexOfFirst { it != null && exitRegex.containsMatchIn(it) } + return when { + match >= 0 -> match + titles.size == 1 -> 0 + else -> -1 + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 61443cc45..5432ac7c6 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -775,6 +775,11 @@ object FeatureRegistry { R.string.island_stopwatch_default, "island_show_timers", ), + SearchSetting( + R.string.island_show_navigation_title, + R.string.island_navigation_desc, + "island_show_navigation", + ), SearchSetting( R.string.feat_caffeinate_title, R.string.caffeinate_notification_desc, diff --git a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt index ee2e056fb..b52a0310b 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt @@ -21,6 +21,7 @@ object IslandPriority { const val CALENDAR_OVERRIDE = 6 const val NOTIFICATION = 10 + const val NAVIGATION = 14 const val PROGRESS = 15 const val FLASHLIGHT = 20 const val TIMER = 25 diff --git a/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt index 59bd90675..07d1e54c1 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt @@ -6,6 +6,7 @@ object IslandPriorityEntries { val all = listOf( Entry("battery", setOf("battery_alerts_plugin"), IslandPriority.BATTERY_ALERT), Entry("notifications", setOf("notifications"), IslandPriority.NOTIFICATION), + Entry("navigation", setOf("navigation"), IslandPriority.NAVIGATION), Entry("flashlight", setOf("flashlight"), IslandPriority.FLASHLIGHT), Entry("timer", setOf("timer"), IslandPriority.TIMER), Entry("media", setOf("media"), IslandPriority.MEDIA), diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationExpanded.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationExpanded.kt new file mode 100644 index 000000000..d502e6aea --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationExpanded.kt @@ -0,0 +1,93 @@ +package com.sameerasw.essentials.island.plugins.navigation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.model.NavigationNotificationData +import com.sameerasw.essentials.island.model.IslandExpandedScope +import com.sameerasw.essentials.island.ui.IslandTextStyles +import com.sameerasw.essentials.island.ui.components.CameraRow +import com.sameerasw.essentials.island.ui.components.ConnectedButtonRow +import com.sameerasw.essentials.island.ui.components.ConnectedItem +import com.sameerasw.essentials.island.ui.components.ConnectedTextLabel +import com.sameerasw.essentials.island.ui.components.MarqueeText + +@Composable +fun NavigationExpanded( + data: NavigationNotificationData, + accent: Color, + onOpen: () -> Unit, + onExit: (() -> Unit)?, + scope: IslandExpandedScope, +) { + val spec = scope.spec + val sidePadding = spec.expandedPadding + spec.expandedCorner * 0.35f + Box(propagateMinConstraints = true) { + Column(Modifier.fillMaxWidth().padding(spec.expandedOutset)) { + Spacer(Modifier.height(spec.expandedTopPadding)) + scope.CameraRow( + horizontalPadding = spec.cameraGap + spec.expandedCorner * 0.35f, + start = { + Maneuver(data, spec.cellSize.value.toInt()) + MarqueeText( + text = data.distance ?: data.instruction.orEmpty(), + style = IslandTextStyles.title.copy(color = accent), + modifier = Modifier.weight(1f), + ) + }, + end = {}, + ) + val instruction = data.instruction.takeIf { data.distance != null } + if (!instruction.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = instruction, + style = IslandTextStyles.body.copy(color = Color.White, fontSize = 17.sp, lineHeight = 22.sp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding), + ) + } + if (!data.eta.isNullOrBlank()) { + Spacer(Modifier.height(2.dp)) + Text( + text = data.eta, + style = IslandTextStyles.body.copy(color = Color.White.copy(alpha = 0.7f)), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding), + ) + } + Spacer(Modifier.height(16.dp)) + ConnectedButtonRow( + height = 44.dp, + modifier = Modifier.padding(horizontal = sidePadding).padding(bottom = spec.expandedBottomPadding), + container = Color.White.copy(alpha = 0.2f), + items = listOfNotNull( + ConnectedItem({ + onOpen() + scope.collapse() + }) { ConnectedTextLabel(stringResource(R.string.island_progress_open)) }, + onExit?.let { exit -> + ConnectedItem({ + exit() + scope.collapse() + }) { ConnectedTextLabel(stringResource(R.string.island_navigation_exit)) } + }, + ), + ) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationPlugin.kt new file mode 100644 index 000000000..b5400353d --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationPlugin.kt @@ -0,0 +1,177 @@ +package com.sameerasw.essentials.island.plugins.navigation + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.domain.model.NavigationNotificationData +import com.sameerasw.essentials.domain.model.NavigationParser +import com.sameerasw.essentials.island.model.CompactCell +import com.sameerasw.essentials.island.model.CompactPlacement +import com.sameerasw.essentials.island.model.ExpandedContent +import com.sameerasw.essentials.island.model.IslandItem +import com.sameerasw.essentials.island.model.IslandPluginContext +import com.sameerasw.essentials.island.model.IslandPriority +import com.sameerasw.essentials.island.model.IslandStage +import com.sameerasw.essentials.island.model.LineContent +import com.sameerasw.essentials.island.model.PluginRequest +import com.sameerasw.essentials.island.plugins.BaseIslandPlugin +import com.sameerasw.essentials.island.plugins.launchPackage +import com.sameerasw.essentials.island.plugins.sendPendingIntent +import com.sameerasw.essentials.island.ui.components.IslandBitmap +import com.sameerasw.essentials.island.ui.components.IslandIcon +import com.sameerasw.essentials.island.ui.components.RollingText +import com.sameerasw.essentials.services.NotificationListener + +class NavigationPlugin : BaseIslandPlugin() { + override val id = "navigation" + + override val settingKeys = setOf( + SettingsRepository.KEY_ISLAND_SHOW_NAVIGATION, + SettingsRepository.KEY_ISLAND_NAVIGATION_ON_NEW, + SettingsRepository.KEY_ISLAND_NAVIGATION_PEEK_AFTER_EXPAND, + SettingsRepository.KEY_ISLAND_NAVIGATION_EXPAND_DISTANCE, + ) + + private var data: NavigationNotificationData? = null + + // Instruction the island already expanded for, so each turn expands once + private var expandedFor: String? = null + + private val endExpand = Runnable { + val c = ctx ?: return@Runnable + // The user closed it or switched to something else + if (c.focusedKey() != ITEM_KEY || c.currentStage() != IslandStage.Expanded) return@Runnable + c.request(PluginRequest.Collapse(ITEM_KEY)) + if (settings.isIslandNavigationPeekAfterExpandEnabled() && settings.isIslandLineStageEnabled()) { + c.mainHandler.postDelayed(peekAfterCollapse, COLLAPSE_ANIM_MS) + } + } + + private val peekAfterCollapse = Runnable { + ctx?.request?.invoke(PluginRequest.Peek(ITEM_KEY, settings.getIslandPeekDurationMs())) + } + + private val listener = object : NotificationListener.NavigationNotificationListener { + override fun onNavigationNotificationUpdated(data: NavigationNotificationData?) { + ctx?.mainHandler?.post { update(data) } + } + } + + override fun onStart() { + NotificationListener.addNavigationNotificationListener(listener) + } + + override fun onStop() { + NotificationListener.removeNavigationNotificationListener(listener) + ctx?.mainHandler?.removeCallbacks(endExpand) + ctx?.mainHandler?.removeCallbacks(peekAfterCollapse) + data = null + expandedFor = null + } + + override fun refresh() { + ctx ?: return + data = NotificationListener.getLatestNavigationNotification() + render() + } + + private fun update(next: NavigationNotificationData?) { + val c = ctx ?: return + val turned = next?.instruction != null && next.instruction != data?.instruction + data = next + render() + if (next == null) { + expandedFor = null + c.mainHandler.removeCallbacks(endExpand) + c.mainHandler.removeCallbacks(peekAfterCollapse) + return + } + if (!enabled()) return + val instruction = next.instruction.orEmpty() + val threshold = settings.getIslandNavigationExpandDistance() + val meters = NavigationParser.meters(next.distance) + // Distance jumped back up: a new maneuver with the same text ("Turn right" twice in a row) + if (meters != null && meters > threshold * REARM_FACTOR) expandedFor = null + val approaching = threshold > 0 && meters != null && meters <= threshold && instruction != expandedFor + when { + approaching && expand(c) -> expandedFor = instruction + !turned -> {} + settings.getIslandNavigationOnNew() == SettingsRepository.ISLAND_NAVIGATION_ON_NEW_EXPAND -> expand(c) + settings.getIslandNavigationOnNew() == SettingsRepository.ISLAND_NAVIGATION_ON_NEW_PEEK && settings.isIslandLineStageEnabled() -> + c.request(PluginRequest.Peek(ITEM_KEY, settings.getIslandPeekDurationMs())) + } + } + + // Expands for a while, then collapses to a line peek or compact + private fun expand(c: IslandPluginContext): Boolean { + // Don't take over something the user is looking at; retried on the next distance update + val focused = c.focusedKey() + if (focused != null && focused != ITEM_KEY) return false + c.mainHandler.removeCallbacks(peekAfterCollapse) + c.request(PluginRequest.Expand(ITEM_KEY)) + c.mainHandler.removeCallbacks(endExpand) + c.mainHandler.postDelayed(endExpand, EXPANDED_MS) + return true + } + + private fun enabled() = settings.isIslandShowNavigationEnabled() + + private fun render() { + val d = data + if (ctx == null || d == null || !enabled()) { + publish(null) + return + } + publish( + IslandItem( + key = ITEM_KEY, + priority = IslandPriority.NAVIGATION, + placement = CompactPlacement.Dynamic, + compact = listOfNotNull( + d.distance?.let { CompactCell("navigation.distance") { RollingText(it) } }, + CompactCell("navigation.maneuver") { Maneuver(d, 18) }, + ), + line = LineContent( + icon = { Maneuver(d, 20) }, + start = d.instruction.orEmpty(), + end = d.distance.orEmpty(), + ), + expanded = ExpandedContent { scope -> + NavigationExpanded( + data = d, + accent = MaterialTheme.colorScheme.primary, + onOpen = { open(d) }, + onExit = d.exitIntent?.let { intent -> { sendPendingIntent(context, intent) } }, + scope = scope, + ) + }, + onOpen = { open(d) }, + sourcePackage = d.packageName, + ), + ) + } + + private fun open(d: NavigationNotificationData) { + if (!sendPendingIntent(context, d.contentIntent)) launchPackage(context, d.packageName) + } + + companion object { + const val ITEM_KEY = "navigation" + + // ponytail: fixed timings; make them settings if people ask + private const val EXPANDED_MS = 6_000L + private const val COLLAPSE_ANIM_MS = 600L + private const val REARM_FACTOR = 1.5 + } +} + +@Composable +internal fun Maneuver(d: NavigationNotificationData, size: Int) { + if (d.maneuver != null) { + IslandBitmap(d.maneuver, size.dp, fallbackRes = R.drawable.round_navigation_24) + } else { + IslandIcon(R.drawable.round_navigation_24, size = size.dp, tint = MaterialTheme.colorScheme.primary) + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt b/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt index 6885c88ea..3f984312d 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt @@ -42,6 +42,7 @@ import com.sameerasw.essentials.island.model.IslandStage import com.sameerasw.essentials.island.plugins.calendar.CalendarPlugin import com.sameerasw.essentials.island.plugins.consciousgate.ConsciousGatePlugin import com.sameerasw.essentials.island.plugins.flashlight.FlashlightPlugin +import com.sameerasw.essentials.island.plugins.navigation.NavigationPlugin import com.sameerasw.essentials.island.plugins.network.NetworkPlugin import com.sameerasw.essentials.island.plugins.signal.SignalPlugin import com.sameerasw.essentials.island.plugins.devices.DevicesPlugin @@ -139,6 +140,7 @@ class IslandCoordinator( RemoteProvidersPlugin(), CaffeinatePlugin(), TravelPlugin(), + NavigationPlugin(), NetworkPlugin(), SignalPlugin(), DevicesPlugin(), diff --git a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt index 02528f2b1..b7ec5962c 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt @@ -47,6 +47,8 @@ import com.sameerasw.essentials.domain.model.DashConfig import com.sameerasw.essentials.domain.model.NotificationActionItem import com.sameerasw.essentials.domain.model.NotificationLightingColorMode import com.sameerasw.essentials.domain.model.NotificationLightingSide +import com.sameerasw.essentials.domain.model.NavigationNotificationData +import com.sameerasw.essentials.domain.model.NavigationParser import com.sameerasw.essentials.domain.model.ProgressNotificationData import com.sameerasw.essentials.domain.model.RippleConfig import com.sameerasw.essentials.services.receivers.FlashlightActionReceiver @@ -68,6 +70,10 @@ class NotificationListener : NotificationListenerService() { fun onProgressNotificationUpdated(data: ProgressNotificationData?) } + interface NavigationNotificationListener { + fun onNavigationNotificationUpdated(data: NavigationNotificationData?) + } + interface NotificationAlertListener { fun onNotificationAlertPosted(alert: ActiveNotificationAlert) fun onNotificationAlertRemoved(key: String) @@ -75,6 +81,7 @@ class NotificationListener : NotificationListenerService() { companion object { private const val PROGRESS_REFRESH_DEBOUNCE_MS = 250L + private const val EXTRA_SHORT_CRITICAL_TEXT = "android.shortCriticalText" const val ACTION_LIKE_CURRENT_SONG = "com.sameerasw.essentials.ACTION_LIKE_CURRENT_SONG" const val ACTION_REQUEST_AMBIENT_GLANCE = @@ -87,6 +94,25 @@ class NotificationListener : NotificationListenerService() { private val progressListeners = mutableListOf() private val alertListeners = mutableListOf() + private val navigationListeners = mutableListOf() + + fun addNavigationNotificationListener(listener: NavigationNotificationListener) { + synchronized(navigationListeners) { + if (!navigationListeners.contains(listener)) navigationListeners.add(listener) + } + } + + fun removeNavigationNotificationListener(listener: NavigationNotificationListener) { + synchronized(navigationListeners) { navigationListeners.remove(listener) } + } + + fun notifyNavigationListeners(data: NavigationNotificationData?) { + val listenersCopy = synchronized(navigationListeners) { navigationListeners.toList() } + listenersCopy.forEach { it.onNavigationNotificationUpdated(data) } + } + + fun getLatestNavigationNotification(): NavigationNotificationData? = + instance?.extractLatestNavigationNotification() fun addNotificationAlertListener(listener: NotificationAlertListener) { synchronized(alertListeners) { @@ -1021,6 +1047,9 @@ class NotificationListener : NotificationListenerService() { val extras = sbn.notification.extras if (extras != null && (extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0) > 0 || extras.containsKey(Notification.EXTRA_PROGRESS_INDETERMINATE))) { scheduleProgressRefresh() + } else if (sbn.isOngoing && isNavigationNotification(sbn)) { + // Navigation shares the progress refresh pass + scheduleProgressRefresh() } else if (HilightProgressTracker.isTracking(sbn.key)) { // Its progress bar was replaced, e.g. by "Download complete" scheduleProgressRefresh() @@ -1876,7 +1905,17 @@ class NotificationListener : NotificationListenerService() { return@execute } HilightProgressTracker.onProgress(applicationContext, data) - progressHandler.post { notifyProgressListeners(data) } + val navigation = + try { + extractLatestNavigationNotification() + } catch (e: Exception) { + Log.e("NotificationListener", "Failed to extract navigation notification", e) + null + } + progressHandler.post { + notifyProgressListeners(data) + notifyNavigationListeners(navigation) + } } } @@ -1907,11 +1946,56 @@ class NotificationListener : NotificationListenerService() { val active = (safeActiveNotifications() ?: return null).toList() + LiveUpdateSnoozer.snoozedNotifications() val progressNotifs = active.mapNotNull { sbn -> if (sbn.packageName == packageName || isMediaNotification(sbn)) return@mapNotNull null + if (islandShowsNavigation && sbn.isOngoing && isNavigationNotification(sbn)) return@mapNotNull null extractProgressNotification(sbn) } return progressNotifs.maxByOrNull { it.postTime } } + // The island's navigation item replaces the progress ring for these + private val islandShowsNavigation: Boolean + get() = getSharedPreferences(SettingsRepository.PREFS_NAME, MODE_PRIVATE).let { + it.getBoolean(SettingsRepository.KEY_ISLAND_ENABLED, false) && + it.getBoolean(SettingsRepository.KEY_ISLAND_SHOW_NAVIGATION, true) + } + + fun extractLatestNavigationNotification(): NavigationNotificationData? { + val active = (safeActiveNotifications() ?: return null).toList() + LiveUpdateSnoozer.snoozedNotifications() + return active + .filter { it.packageName != packageName && it.isOngoing && isNavigationNotification(it) } + .mapNotNull { extractNavigationNotification(it) } + .maxByOrNull { it.postTime } + } + + private fun extractNavigationNotification(sbn: StatusBarNotification): NavigationNotificationData? { + val notif = sbn.notification ?: return null + val extras = notif.extras ?: return null + val parsed = NavigationParser.parse( + title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString(), + text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString(), + subText = extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.toString(), + chip = extras.getCharSequence(EXTRA_SHORT_CRITICAL_TEXT)?.toString(), + ) + val maneuver = try { + notif.getLargeIcon()?.loadDrawable(this)?.let { AppUtil.drawableToBitmap(it) } + } catch (_: Exception) { + null + } + val actions = notif.actions.orEmpty() + val exit = NavigationParser.exitActionIndex(actions.map { it.title?.toString() }) + return NavigationNotificationData( + key = sbn.key, + packageName = sbn.packageName, + instruction = parsed.instruction, + distance = parsed.distance, + eta = parsed.eta, + maneuver = maneuver, + contentIntent = notif.contentIntent, + exitIntent = actions.getOrNull(exit)?.actionIntent, + postTime = if (sbn.postTime > 0) sbn.postTime else notif.`when`, + ) + } + fun isHeadsUpNotification( sbn: StatusBarNotification, rankingMap: RankingMap? = null, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt index 0444787cf..81ba98c9a 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt @@ -81,6 +81,8 @@ import com.sameerasw.essentials.ui.features.display.sheets.IslandBorderOutlineOp import com.sameerasw.essentials.ui.features.display.sheets.IslandBriefOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandCallOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandDevicesBatteryBottomSheet +import com.sameerasw.essentials.ui.features.display.sheets.IslandNavigationOptionsBottomSheet +import com.sameerasw.essentials.ui.features.display.sheets.IslandNotificationOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandPulseShadowOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandSoundModeOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandTimeOptionsBottomSheet @@ -123,6 +125,7 @@ fun IslandSettingsUI( var showCallOptionsSheet by remember { mutableStateOf(false) } var showSignalOptionsSheet by remember { mutableStateOf(false) } var showAlarmOptionsSheet by remember { mutableStateOf(false) } + var showNavigationOptionsSheet by remember { mutableStateOf(false) } var showBriefOptionsSheet by remember { mutableStateOf(highlightSetting == "island_brief_show_alarm") } var pickingGesture by remember { mutableStateOf(null) } var showSlideModeSheet by remember { mutableStateOf(false) } @@ -270,14 +273,7 @@ fun IslandSettingsUI( HapticUtil.performVirtualKeyHaptic(view) viewModel.setIslandShowNotifications(checked) }, - onSettingsClick = { - HapticUtil.performVirtualKeyHaptic(view) - context.startActivity( - Intent(context, FeatureSettingsActivity::class.java).apply { - putExtra("feature", ISLAND_NOTIFICATIONS_FEATURE_ID) - }, - ) - }, + onSettingsClick = { showNotificationOptionsSheet = true }, modifier = Modifier.highlight(highlightSetting == "island_show_notifications"), ) "time" -> IconToggleItem( @@ -430,6 +426,19 @@ fun IslandSettingsUI( onSettingsClick = { showAlarmOptionsSheet = true }, modifier = Modifier.highlight(highlightSetting == "island_show_alarm"), ) + "navigation" -> IconToggleItem( + dragHandle = handle, + iconRes = R.drawable.round_navigation_24, + title = stringResource(R.string.island_show_navigation_title), + description = stringResource(R.string.island_navigation_desc), + isChecked = viewModel.isIslandShowNavigation.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setIslandShowNavigation(checked) + }, + onSettingsClick = { showNavigationOptionsSheet = true }, + modifier = Modifier.highlight(highlightSetting == "island_show_navigation"), + ) "devices" -> IconToggleItem( dragHandle = handle, iconRes = R.drawable.rounded_bluetooth_24, @@ -760,6 +769,14 @@ fun IslandSettingsUI( ) } + if (showNotificationOptionsSheet) { + IslandNotificationOptionsBottomSheet( + viewModel = viewModel, + onDismissRequest = { showNotificationOptionsSheet = false }, + highlightSetting = highlightSetting, + ) + } + if (showBriefOptionsSheet) { IslandBriefOptionsBottomSheet( viewModel = viewModel, @@ -775,6 +792,10 @@ fun IslandSettingsUI( ) } + if (showNavigationOptionsSheet) { + IslandNavigationOptionsBottomSheet(onDismissRequest = { showNavigationOptionsSheet = false }) + } + if (showAlarmOptionsSheet) { IslandAlarmOptionsBottomSheet( viewModel = viewModel, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNavigationOptionsBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNavigationOptionsBottomSheet.kt new file mode 100644 index 000000000..432ed257d --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNavigationOptionsBottomSheet.kt @@ -0,0 +1,132 @@ +package com.sameerasw.essentials.ui.features.display.sheets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem +import com.sameerasw.essentials.ui.core.cards.ConfigPickerItem +import com.sameerasw.essentials.ui.core.cards.IconToggleItem +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.core.pickers.SegmentedPicker +import com.sameerasw.essentials.ui.core.sheets.EssentialsBottomSheet +import com.sameerasw.essentials.utils.HapticUtil + +private val EXPAND_DISTANCE_OPTIONS = listOf(0, 50, 100, 200, 500) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IslandNavigationOptionsBottomSheet(onDismissRequest: () -> Unit) { + val context = LocalContext.current + val view = LocalView.current + val settings = remember { SettingsRepository(context) } + var onNew by remember { mutableStateOf(settings.getIslandNavigationOnNew()) } + var expandDistance by remember { mutableStateOf(settings.getIslandNavigationExpandDistance()) } + var peekAfterExpand by remember { mutableStateOf(settings.isIslandNavigationPeekAfterExpandEnabled()) } + + @Composable + fun distanceLabel(meters: Int) = + if (meters == 0) stringResource(R.string.island_navigation_expand_off) else stringResource(R.string.island_navigation_expand_meters, meters) + + EssentialsBottomSheet(onDismissRequest = onDismissRequest) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.island_navigation_options_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 8.dp, bottom = 4.dp), + ) + + RoundedCardContainer(spacing = 2.dp, cornerRadius = 24.dp) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.extraSmall) + .padding(top = 12.dp), + ) { + Text( + text = stringResource(R.string.island_navigation_on_new_title), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Text( + text = stringResource(R.string.island_navigation_on_new_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + val labels = mapOf( + SettingsRepository.ISLAND_NAVIGATION_ON_NEW_EXPAND to stringResource(R.string.island_navigation_on_new_expand), + SettingsRepository.ISLAND_NAVIGATION_ON_NEW_PEEK to stringResource(R.string.island_navigation_on_new_peek), + SettingsRepository.ISLAND_NAVIGATION_ON_NEW_OFF to stringResource(R.string.island_navigation_expand_off), + ) + SegmentedPicker( + items = labels.keys.toList(), + selectedItem = onNew, + onItemSelected = { + onNew = it + settings.setIslandNavigationOnNew(it) + }, + labelProvider = { labels[it].orEmpty() }, + title = R.string.island_navigation_on_new_title, + modifier = Modifier.fillMaxWidth(), + ) + } + + ConfigPickerItem( + title = stringResource(R.string.island_navigation_expand_title), + description = stringResource(R.string.island_navigation_expand_desc), + selectedValue = distanceLabel(expandDistance), + iconRes = R.drawable.rounded_magnify_fullscreen_24, + modifier = Modifier.fillMaxWidth(), + ) { + EXPAND_DISTANCE_OPTIONS.forEach { meters -> + SegmentedDropdownMenuItem( + text = { Text(distanceLabel(meters)) }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + expandDistance = meters + settings.setIslandNavigationExpandDistance(meters) + }, + ) + } + } + + IconToggleItem( + iconRes = R.drawable.round_navigation_24, + title = stringResource(R.string.island_navigation_peek_after_expand_title), + description = stringResource(R.string.island_navigation_peek_after_expand_desc), + isChecked = peekAfterExpand, + onCheckedChange = { + HapticUtil.performVirtualKeyHaptic(view) + peekAfterExpand = it + settings.setIslandNavigationPeekAfterExpandEnabled(it) + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 99f679edd..1f8ee9b1f 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -241,6 +241,7 @@ class MainViewModel : ViewModel() { val islandAlarmWindowHours = mutableIntStateOf(12) val isIslandShowTravel = mutableStateOf(true) val isIslandShowCaffeinate = mutableStateOf(true) + val isIslandShowNavigation = mutableStateOf(true) val isIslandShowDevices = mutableStateOf(true) val isIslandBriefEnabled = mutableStateOf(false) val isIslandBriefShowAlarm = mutableStateOf(true) @@ -2406,6 +2407,7 @@ class MainViewModel : ViewModel() { isDuoKeepInFullscreen.value = settingsRepository.isDuoKeepInFullscreenEnabled() isDuoDisableInteractionsInFullscreen.value = settingsRepository.isDuoDisableInteractionsInFullscreenEnabled() isIslandShowCaffeinate.value = settingsRepository.isIslandShowCaffeinateEnabled() + isIslandShowNavigation.value = settingsRepository.isIslandShowNavigationEnabled() isIslandShowDevices.value = settingsRepository.isIslandShowDevicesEnabled() isIslandBriefEnabled.value = settingsRepository.isIslandBriefEnabled() isIslandBriefShowAlarm.value = settingsRepository.isIslandBriefShowAlarmEnabled() @@ -5492,6 +5494,11 @@ class MainViewModel : ViewModel() { settingsRepository.setIslandShowDevicesEnabled(enabled) } + fun setIslandShowNavigation(enabled: Boolean) { + isIslandShowNavigation.value = enabled + settingsRepository.setIslandShowNavigationEnabled(enabled) + } + fun setIslandShowCaffeinate(enabled: Boolean) { isIslandShowCaffeinate.value = enabled settingsRepository.setIslandShowCaffeinateEnabled(enabled) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 42b5f4366..c278a4315 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3157,6 +3157,20 @@ Combine with Island Combine with Duo Devices + Navigation + Next turn and distance from Maps and other navigation apps + Navigation + Exit + New instruction + What the island does when the next direction appears + Expand + Line peek + Expand before a turn + Expands with the direction when you get this close to the turn + Off + %d m + Line peek after expanding + When off, the island goes straight back to compact Connected Disconnected Bluetooth device diff --git a/app/src/test/java/com/sameerasw/essentials/domain/NavigationParserTest.kt b/app/src/test/java/com/sameerasw/essentials/domain/NavigationParserTest.kt new file mode 100644 index 000000000..cfeb434da --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/domain/NavigationParserTest.kt @@ -0,0 +1,67 @@ +package com.sameerasw.essentials.domain + +import com.sameerasw.essentials.domain.model.NavigationParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NavigationParserTest { + @Test + fun distanceInTitle() { + val p = NavigationParser.parse("300 m", "Turn right onto Main St", "12 min · 4.5 km", null) + assertEquals("300 m", p.distance) + assertEquals("Turn right onto Main St", p.instruction) + assertEquals("12 min · 4.5 km", p.eta) + } + + @Test + fun distanceInText() { + val p = NavigationParser.parse("Gira a la derecha hacia Av. Juárez", "1,2 km", null, null) + assertEquals("1,2 km", p.distance) + assertEquals("Gira a la derecha hacia Av. Juárez", p.instruction) + assertNull(p.eta) + } + + @Test + fun chipWinsForDistance() { + val p = NavigationParser.parse("Turn left", "Toward downtown", "3:45 PM", "0.2 mi") + assertEquals("0.2 mi", p.distance) + assertEquals("Turn left", p.instruction) + assertEquals("3:45 PM", p.eta) + } + + @Test + fun noDistanceKeepsTextAsDetail() { + val p = NavigationParser.parse("Head north", "Toward Main St", null, null) + assertNull(p.distance) + assertEquals("Head north", p.instruction) + assertEquals("Toward Main St", p.eta) + } + + @Test + fun longTextIsNotADistance() { + val p = NavigationParser.parse("Continue on Highway 15D for 25 km", null, null, null) + assertNull(p.distance) + } + + @Test + fun exitAction() { + assertEquals(1, NavigationParser.exitActionIndex(listOf("Mute", "Exit navigation"))) + assertEquals(0, NavigationParser.exitActionIndex(listOf("Salir"))) + assertEquals(0, NavigationParser.exitActionIndex(listOf("Something"))) + assertEquals(-1, NavigationParser.exitActionIndex(listOf("Mute", "Send"))) + assertEquals(-1, NavigationParser.exitActionIndex(emptyList())) + } + + @Test + fun meters() { + assertEquals(240 * 0.9144, NavigationParser.meters("240 yd")!!, 0.01) + assertEquals(1200.0, NavigationParser.meters("1,2 km")!!, 0.01) + assertEquals(1200 * 0.3048, NavigationParser.meters("1,200 ft")!!, 0.01) + assertEquals(0.2 * 1609.344, NavigationParser.meters("0.2 mi")!!, 0.01) + assertEquals(300.0, NavigationParser.meters("300 m")!!, 0.01) + assertEquals(50.0, NavigationParser.meters("In 50m")!!, 0.01) + assertNull(NavigationParser.meters("Western Road")) + assertNull(NavigationParser.meters(null)) + } +} From 938dc1b95a40c1c922a75ec5a00d7e64977ffc74 Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Tue, 13 Oct 2026 15:38:10 -0700 Subject: [PATCH 02/12] feat(island): add navigation plugin and settings to island --- .../plugins/navigation/NavigationExpanded.kt | 27 ++++--- .../plugins/navigation/NavigationPlugin.kt | 78 +++++++++++++++---- .../ui/features/display/IslandSettingsUI.kt | 20 ++--- 3 files changed, 83 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationExpanded.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationExpanded.kt index d502e6aea..1d9eefe7c 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationExpanded.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationExpanded.kt @@ -1,5 +1,6 @@ package com.sameerasw.essentials.island.plugins.navigation +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -12,17 +13,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.model.NavigationNotificationData import com.sameerasw.essentials.island.model.IslandExpandedScope +import com.sameerasw.essentials.island.ui.IslandLayoutSpec import com.sameerasw.essentials.island.ui.IslandTextStyles import com.sameerasw.essentials.island.ui.components.CameraRow import com.sameerasw.essentials.island.ui.components.ConnectedButtonRow import com.sameerasw.essentials.island.ui.components.ConnectedItem import com.sameerasw.essentials.island.ui.components.ConnectedTextLabel -import com.sameerasw.essentials.island.ui.components.MarqueeText +import com.sameerasw.essentials.ui.theme.EssentialsTheme @Composable fun NavigationExpanded( @@ -39,22 +42,18 @@ fun NavigationExpanded( Spacer(Modifier.height(spec.expandedTopPadding)) scope.CameraRow( horizontalPadding = spec.cameraGap + spec.expandedCorner * 0.35f, - start = { - Maneuver(data, spec.cellSize.value.toInt()) - MarqueeText( - text = data.distance ?: data.instruction.orEmpty(), - style = IslandTextStyles.title.copy(color = accent), - modifier = Modifier.weight(1f), - ) + start = { Maneuver(data, spec.cellSize.value.toInt()) }, + end = { + data.distance?.let { + Text(it, style = IslandTextStyles.title.copy(color = accent), maxLines = 1) + } }, - end = {}, ) - val instruction = data.instruction.takeIf { data.distance != null } - if (!instruction.isNullOrBlank()) { - Spacer(Modifier.height(4.dp)) + if (!data.instruction.isNullOrBlank()) { + Spacer(Modifier.height(8.dp)) Text( - text = instruction, - style = IslandTextStyles.body.copy(color = Color.White, fontSize = 17.sp, lineHeight = 22.sp), + text = data.instruction, + style = IslandTextStyles.title.copy(color = Color.White, fontSize = 20.sp, lineHeight = 25.sp), maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding), diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationPlugin.kt index b5400353d..c577b7099 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationPlugin.kt @@ -36,12 +36,19 @@ class NavigationPlugin : BaseIslandPlugin() { private var data: NavigationNotificationData? = null - // Instruction the island already expanded for, so each turn expands once - private var expandedFor: String? = null + private val expandedFor = HashSet() + + private val surfaced = LinkedHashSet() + private var lastSurfacedAt = 0L + + private val endRoute = Runnable { + expandedFor.clear() + surfaced.clear() + lastSurfacedAt = 0L + } private val endExpand = Runnable { val c = ctx ?: return@Runnable - // The user closed it or switched to something else if (c.focusedKey() != ITEM_KEY || c.currentStage() != IslandStage.Expanded) return@Runnable c.request(PluginRequest.Collapse(ITEM_KEY)) if (settings.isIslandNavigationPeekAfterExpandEnabled() && settings.isIslandLineStageEnabled()) { @@ -67,8 +74,9 @@ class NavigationPlugin : BaseIslandPlugin() { NotificationListener.removeNavigationNotificationListener(listener) ctx?.mainHandler?.removeCallbacks(endExpand) ctx?.mainHandler?.removeCallbacks(peekAfterCollapse) + ctx?.mainHandler?.removeCallbacks(endRoute) data = null - expandedFor = null + endRoute.run() } override fun refresh() { @@ -83,32 +91,57 @@ class NavigationPlugin : BaseIslandPlugin() { data = next render() if (next == null) { - expandedFor = null c.mainHandler.removeCallbacks(endExpand) c.mainHandler.removeCallbacks(peekAfterCollapse) + c.mainHandler.removeCallbacks(endRoute) + c.mainHandler.postDelayed(endRoute, ROUTE_GRACE_MS) return } + c.mainHandler.removeCallbacks(endRoute) if (!enabled()) return val instruction = next.instruction.orEmpty() val threshold = settings.getIslandNavigationExpandDistance() val meters = NavigationParser.meters(next.distance) // Distance jumped back up: a new maneuver with the same text ("Turn right" twice in a row) - if (meters != null && meters > threshold * REARM_FACTOR) expandedFor = null - val approaching = threshold > 0 && meters != null && meters <= threshold && instruction != expandedFor + if (meters != null && meters > threshold * REARM_FACTOR) { + expandedFor.remove(instruction) + surfaced.remove(instruction) + } + val approaching = + threshold > 0 && meters != null && meters <= threshold && instruction !in expandedFor + val expandSoon = threshold > 0 && meters != null && meters <= threshold * REARM_FACTOR + val now = System.currentTimeMillis() + val announce = + turned && instruction !in surfaced && !expandSoon && now - lastSurfacedAt >= COOLDOWN_MS + when { - approaching && expand(c) -> expandedFor = instruction - !turned -> {} - settings.getIslandNavigationOnNew() == SettingsRepository.ISLAND_NAVIGATION_ON_NEW_EXPAND -> expand(c) - settings.getIslandNavigationOnNew() == SettingsRepository.ISLAND_NAVIGATION_ON_NEW_PEEK && settings.isIslandLineStageEnabled() -> + approaching && expand(c) -> { + expandedFor += instruction + markSurfaced(instruction, now) + } + + !announce -> {} + settings.getIslandNavigationOnNew() == SettingsRepository.ISLAND_NAVIGATION_ON_NEW_EXPAND -> { + if (expand(c)) markSurfaced(instruction, now) + } + + settings.getIslandNavigationOnNew() == SettingsRepository.ISLAND_NAVIGATION_ON_NEW_PEEK && settings.isIslandLineStageEnabled() -> { c.request(PluginRequest.Peek(ITEM_KEY, settings.getIslandPeekDurationMs())) + markSurfaced(instruction, now) + } } } - // Expands for a while, then collapses to a line peek or compact + private fun markSurfaced(instruction: String, now: Long) { + surfaced += instruction + if (surfaced.size > MAX_SURFACED) surfaced.remove(surfaced.first()) + lastSurfacedAt = now + } + private fun expand(c: IslandPluginContext): Boolean { - // Don't take over something the user is looking at; retried on the next distance update val focused = c.focusedKey() if (focused != null && focused != ITEM_KEY) return false + if (focused == ITEM_KEY && c.currentStage() == IslandStage.Expanded) return true c.mainHandler.removeCallbacks(peekAfterCollapse) c.request(PluginRequest.Expand(ITEM_KEY)) c.mainHandler.removeCallbacks(endExpand) @@ -143,7 +176,14 @@ class NavigationPlugin : BaseIslandPlugin() { data = d, accent = MaterialTheme.colorScheme.primary, onOpen = { open(d) }, - onExit = d.exitIntent?.let { intent -> { sendPendingIntent(context, intent) } }, + onExit = d.exitIntent?.let { intent -> + { + sendPendingIntent( + context, + intent + ) + } + }, scope = scope, ) }, @@ -160,10 +200,12 @@ class NavigationPlugin : BaseIslandPlugin() { companion object { const val ITEM_KEY = "navigation" - // ponytail: fixed timings; make them settings if people ask private const val EXPANDED_MS = 6_000L private const val COLLAPSE_ANIM_MS = 600L private const val REARM_FACTOR = 1.5 + private const val COOLDOWN_MS = 30_000L + private const val ROUTE_GRACE_MS = 60_000L + private const val MAX_SURFACED = 50 } } @@ -172,6 +214,10 @@ internal fun Maneuver(d: NavigationNotificationData, size: Int) { if (d.maneuver != null) { IslandBitmap(d.maneuver, size.dp, fallbackRes = R.drawable.round_navigation_24) } else { - IslandIcon(R.drawable.round_navigation_24, size = size.dp, tint = MaterialTheme.colorScheme.primary) + IslandIcon( + R.drawable.round_navigation_24, + size = size.dp, + tint = MaterialTheme.colorScheme.primary + ) } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt index b4acc256c..b4cf28bdb 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt @@ -74,14 +74,11 @@ import com.sameerasw.essentials.ui.features.display.actions.HorizontalSlideModeS import com.sameerasw.essentials.ui.features.display.actions.horizontalSlideDescription import com.sameerasw.essentials.ui.features.display.sheets.IslandAlarmOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandBatteryOptionsBottomSheet -import com.sameerasw.essentials.ui.features.display.sheets.IslandBorderOutlineOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandBriefOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandAppOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandCallOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandDevicesBatteryBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandNavigationOptionsBottomSheet -import com.sameerasw.essentials.ui.features.display.sheets.IslandNotificationOptionsBottomSheet -import com.sameerasw.essentials.ui.features.display.sheets.IslandPulseShadowOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandSoundModeOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandTimeOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandSignalOptionsBottomSheet @@ -271,7 +268,14 @@ fun IslandSettingsUI( HapticUtil.performVirtualKeyHaptic(view) viewModel.setIslandShowNotifications(checked) }, - onSettingsClick = { showNotificationOptionsSheet = true }, + onSettingsClick = { + HapticUtil.performVirtualKeyHaptic(view) + context.startActivity( + Intent(context, FeatureSettingsActivity::class.java).apply { + putExtra("feature", ISLAND_NOTIFICATIONS_FEATURE_ID) + }, + ) + }, modifier = Modifier.highlight(highlightSetting == "island_show_notifications"), ) "time" -> IconToggleItem( @@ -783,14 +787,6 @@ fun IslandSettingsUI( ) } - if (showNotificationOptionsSheet) { - IslandNotificationOptionsBottomSheet( - viewModel = viewModel, - onDismissRequest = { showNotificationOptionsSheet = false }, - highlightSetting = highlightSetting, - ) - } - if (showBriefOptionsSheet) { IslandBriefOptionsBottomSheet( viewModel = viewModel, From 44caae0260d8188876d5519d1d37d34b03a2410f Mon Sep 17 00:00:00 2001 From: Joseph Kuriackal Date: Thu, 15 Oct 2026 08:52:12 +0530 Subject: [PATCH 03/12] feat: screenshots page with delayed share cleanup, auto-clean and stats Moves "Delete shared screenshots" onto a new Screenshots page under Security and adds: - a delay after sharing (immediately, 1 hour, 1 day) - auto-clean: every new screenshot is trashed after 1, 7 or 30 days, with a quiet notification offering Keep and Trash now; starred screenshots are kept - a count of cleaned screenshots and the storage saved New screenshots are picked up with a MediaStore ContentObserver and each cleanup is a unique WorkManager job keyed by the media id. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/src/main/AndroidManifest.xml | 4 + .../data/repository/SettingsRepository.kt | 21 ++ .../domain/registry/FeatureRegistry.kt | 47 +++- .../domain/registry/PermissionRegistry.kt | 7 +- .../ScreenshotCleanupActionReceiver.kt | 59 +++++ .../services/ScreenshotTrashWorker.kt | 33 +++ .../handlers/ScreenshotAutoCleanHandler.kt | 65 +++++ .../handlers/ScreenshotShareCleanupHandler.kt | 68 ++--- .../tiles/ScreenOffAccessibilityService.kt | 5 + .../ui/activities/FeatureSettingsActivity.kt | 13 +- .../security/ScreenshotsSettingsUI.kt | 232 ++++++++++++++++++ .../essentials/utils/ScreenshotCleaner.kt | 114 +++++++++ .../utils/ScreenshotCleanupNotifier.kt | 135 ++++++++++ .../essentials/viewmodels/MainViewModel.kt | 43 ++++ app/src/main/res/values/strings.xml | 30 +++ 15 files changed, 817 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/receivers/ScreenshotCleanupActionReceiver.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/services/ScreenshotTrashWorker.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotAutoCleanHandler.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/security/ScreenshotsSettingsUI.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleaner.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleanupNotifier.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ed3b93dcf..c2a85aacd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1738,6 +1738,10 @@ android:name=".receivers.UrlShortenerActionReceiver" android:exported="false" /> + + = Build.VERSION_CODES.R }, object : Feature( id = "Lockdown mode", diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt index c6cbab35d..10d21f9f7 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt @@ -199,9 +199,10 @@ fun initPermissionRegistry() { // AOD Wallpaper feature PermissionRegistry.register("STORAGE", R.string.feat_aod_wallpaper_title) - // Screenshot share cleanup feature - PermissionRegistry.register("ACCESSIBILITY", R.string.feat_screenshot_share_cleanup_title) - PermissionRegistry.register("STORAGE", R.string.feat_screenshot_share_cleanup_title) + // Screenshots feature + PermissionRegistry.register("ACCESSIBILITY", R.string.feat_screenshots_title) + PermissionRegistry.register("STORAGE", R.string.feat_screenshots_title) + PermissionRegistry.register("POST_NOTIFICATIONS", R.string.feat_screenshots_title) // Pixel Search features PermissionRegistry.register("STORAGE", R.string.pixel_search_results_files_title) diff --git a/app/src/main/java/com/sameerasw/essentials/receivers/ScreenshotCleanupActionReceiver.kt b/app/src/main/java/com/sameerasw/essentials/receivers/ScreenshotCleanupActionReceiver.kt new file mode 100644 index 000000000..33f7fb3d2 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/receivers/ScreenshotCleanupActionReceiver.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Background Services & Receivers + * File: ScreenshotCleanupActionReceiver.kt + * Description: Handles the Keep and Trash now actions on the screenshot cleanup notification. + */ + +package com.sameerasw.essentials.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.widget.Toast +import com.sameerasw.essentials.R +import com.sameerasw.essentials.utils.ScreenshotCleaner +import com.sameerasw.essentials.utils.ScreenshotCleanupNotifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ScreenshotCleanupActionReceiver : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + val mediaId = intent.getLongExtra(EXTRA_MEDIA_ID, -1L) + if (mediaId < 0) return + ScreenshotCleaner.cancel(context, mediaId) + ScreenshotCleanupNotifier.dismiss(context, mediaId) + + when (intent.action) { + ACTION_KEEP -> Toast.makeText(context, R.string.screenshot_kept, Toast.LENGTH_SHORT).show() + + ACTION_TRASH_NOW -> { + val pending = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + if (ScreenshotCleaner.trash(context, mediaId, keepStarred = false)) { + withContext(Dispatchers.Main) { + Toast.makeText(context, R.string.screenshot_moved_to_trash, Toast.LENGTH_SHORT).show() + } + } + } finally { + pending.finish() + } + } + } + } + } + + companion object { + const val ACTION_KEEP = "com.sameerasw.essentials.action.KEEP_SCREENSHOT" + const val ACTION_TRASH_NOW = "com.sameerasw.essentials.action.TRASH_SCREENSHOT" + const val EXTRA_MEDIA_ID = "extra_media_id" + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/ScreenshotTrashWorker.kt b/app/src/main/java/com/sameerasw/essentials/services/ScreenshotTrashWorker.kt new file mode 100644 index 000000000..ddfef3d95 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/ScreenshotTrashWorker.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Background Services & Receivers + * File: ScreenshotTrashWorker.kt + * Description: Moves a scheduled screenshot to the trash when its cleanup delay runs out. + */ + +package com.sameerasw.essentials.services + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.sameerasw.essentials.utils.ScreenshotCleaner +import com.sameerasw.essentials.utils.ScreenshotCleanupNotifier + +class ScreenshotTrashWorker( + appContext: Context, + workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + val mediaId = inputData.getLong(KEY_MEDIA_ID, -1L) + if (mediaId < 0) return Result.failure() + ScreenshotCleaner.trash(applicationContext, mediaId) + ScreenshotCleanupNotifier.dismiss(applicationContext, mediaId) + return Result.success() + } + + companion object { + const val KEY_MEDIA_ID = "media_id" + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotAutoCleanHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotAutoCleanHandler.kt new file mode 100644 index 000000000..e6ac125c6 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotAutoCleanHandler.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Background Services & Receivers + * File: ScreenshotAutoCleanHandler.kt + * Description: Schedules every new screenshot for the trash and posts a notification to keep it. + */ + +package com.sameerasw.essentials.services.handlers + +import android.content.Context +import android.database.ContentObserver +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.utils.ScreenshotCleaner +import com.sameerasw.essentials.utils.ScreenshotCleanupNotifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class ScreenshotAutoCleanHandler( + private val context: Context, + private val scope: CoroutineScope, +) { + private val settings = SettingsRepository(context) + private val mutex = Mutex() + + private val observer = + object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + scope.launch(Dispatchers.IO) { scheduleNewScreenshots() } + } + } + + fun register() { + context.contentResolver.registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, observer) + } + + fun unregister() { + context.contentResolver.unregisterContentObserver(observer) + } + + private suspend fun scheduleNewScreenshots() { + if (!settings.isScreenshotAutoCleanEnabled()) return + mutex.withLock { + val days = settings.getScreenshotAutoCleanDays() + val since = System.currentTimeMillis() / 1000 - NEW_SCREENSHOT_WINDOW_SECONDS + ScreenshotCleaner.screenshotIdsAddedSince(context, settings.getScreenshotAutoCleanLastId(), since).forEach { id -> + ScreenshotCleaner.schedule(context, id, days * MINUTES_PER_DAY, ScreenshotCleaner.TAG_AUTO_CLEAN) + ScreenshotCleanupNotifier.show(context, id, days) + settings.setScreenshotAutoCleanLastId(id) + } + } + } + + companion object { + private const val MINUTES_PER_DAY = 24 * 60L + private const val NEW_SCREENSHOT_WINDOW_SECONDS = 60L + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotShareCleanupHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotShareCleanupHandler.kt index e48aaccb9..4616a1a0c 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotShareCleanupHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotShareCleanupHandler.kt @@ -4,23 +4,20 @@ * * Feature Module: Background Services & Receivers * File: ScreenshotShareCleanupHandler.kt - * Description: Moves a screenshot to the trash after it is shared from the screenshot preview. + * Description: Moves a screenshot to the trash after it is shared from the screenshot preview, right away or after a delay. */ package com.sameerasw.essentials.services.handlers import android.accessibilityservice.AccessibilityService import android.content.ComponentName -import android.content.ContentUris -import android.content.ContentValues import android.content.res.Resources -import android.net.Uri -import android.os.Environment -import android.provider.MediaStore import android.view.accessibility.AccessibilityEvent import android.widget.Toast import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.utils.ScreenshotCleaner +import com.sameerasw.essentials.utils.ScreenshotCleanupNotifier import com.sameerasw.essentials.utils.ScreenshotShareTracker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -34,7 +31,7 @@ class ScreenshotShareCleanupHandler( ) { private val settings = SettingsRepository(service) private val tracker by lazy { ScreenshotShareTracker(SHARE_SHEET, systemImageEditorPackage() ?: MARKUP) } - private var screenshotUri: Uri? = null + private var screenshotId: Long? = null private val shareDescription by lazy { systemUiString("screenshot_share_description") ?: "Share screenshot" } @@ -46,8 +43,8 @@ class ScreenshotShareCleanupHandler( if (event.packageName == SYSTEM_UI && event.contentDescription?.startsWith(shareDescription) == true) { val now = System.currentTimeMillis() tracker.onShareTapped(now, foregroundPackage) - screenshotUri = null - scope.launch(Dispatchers.IO) { screenshotUri = findScreenshot(now) } + screenshotId = null + scope.launch(Dispatchers.IO) { screenshotId = findScreenshot(now) } } } @@ -55,53 +52,41 @@ class ScreenshotShareCleanupHandler( if (packageName == null) return val sharedAt = tracker.sharedAt if (tracker.onForegroundPackage(packageName, System.currentTimeMillis())) { - trashScreenshot(screenshotUri, sharedAt) + trashScreenshot(screenshotId, sharedAt) } } fun onScreenOff() = tracker.onScreenOff() private fun trashScreenshot( - capturedUri: Uri?, + capturedId: Long?, sharedAt: Long, ) { scope.launch { - delay(TRASH_DELAY_MS) - val trashed = + val delayMinutes = settings.getScreenshotShareCleanupDelayMinutes() + if (delayMinutes <= 0) delay(TRASH_DELAY_MS) + val outcome = withContext(Dispatchers.IO) { - val uri = capturedUri ?: findScreenshot(sharedAt) ?: return@withContext false - runCatching { - val values = ContentValues().apply { put(MediaStore.MediaColumns.IS_TRASHED, 1) } - service.contentResolver.update(uri, values, null) > 0 - }.getOrDefault(false) + val mediaId = capturedId ?: findScreenshot(sharedAt) ?: return@withContext null + ScreenshotCleanupNotifier.dismiss(service, mediaId) + if (delayMinutes > 0) { + ScreenshotCleaner.schedule(service, mediaId, delayMinutes.toLong(), ScreenshotCleaner.TAG_SHARED) + return@withContext R.string.screenshot_share_cleanup_scheduled + } + ScreenshotCleaner.cancel(service, mediaId) + if (ScreenshotCleaner.trash(service, mediaId)) R.string.screenshot_share_cleanup_trashed else null } - if (trashed) { - Toast.makeText(service, R.string.screenshot_share_cleanup_trashed, Toast.LENGTH_SHORT).show() - } + outcome?.let { Toast.makeText(service, it, Toast.LENGTH_SHORT).show() } } } - private fun findScreenshot(sharedAt: Long): Uri? { - val collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI - val selection = - "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.DATE_ADDED} BETWEEN ? AND ?" + private fun findScreenshot(sharedAt: Long): Long? { val sharedAtSeconds = sharedAt / 1000 - val args = - arrayOf( - SCREENSHOTS_PATH, - (sharedAtSeconds - SCREENSHOT_LOOKBACK_SECONDS).toString(), - (sharedAtSeconds + SCREENSHOT_LOOKAHEAD_SECONDS).toString(), - ) - return service.contentResolver - .query( - collection, - arrayOf(MediaStore.MediaColumns._ID), - selection, - args, - "${MediaStore.MediaColumns.DATE_ADDED} DESC", - )?.use { cursor -> - if (cursor.moveToFirst()) ContentUris.withAppendedId(collection, cursor.getLong(0)) else null - } + return ScreenshotCleaner.findScreenshotAddedBetween( + service, + sharedAtSeconds - SCREENSHOT_LOOKBACK_SECONDS, + sharedAtSeconds + SCREENSHOT_LOOKAHEAD_SECONDS, + ) } private fun systemUiString(name: String): String? = @@ -122,7 +107,6 @@ class ScreenshotShareCleanupHandler( private const val SYSTEM_UI = "com.android.systemui" private const val SHARE_SHEET = "com.android.intentresolver" private const val MARKUP = "com.google.android.markup" - private val SCREENSHOTS_PATH = "${Environment.DIRECTORY_PICTURES}/${Environment.DIRECTORY_SCREENSHOTS}/" private const val SCREENSHOT_LOOKBACK_SECONDS = 120L private const val SCREENSHOT_LOOKAHEAD_SECONDS = 5L private const val TRASH_DELAY_MS = 60_000L diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index e7b75d486..2f0ef76e3 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -46,6 +46,7 @@ import com.sameerasw.essentials.services.handlers.NotificationLightingHandler import com.sameerasw.essentials.services.handlers.OmniGestureOverlayHandler import com.sameerasw.essentials.services.handlers.FaceUnlockBrightnessHandler import com.sameerasw.essentials.services.handlers.PocketModeHandler +import com.sameerasw.essentials.services.handlers.ScreenshotAutoCleanHandler import com.sameerasw.essentials.services.handlers.ScreenshotShareCleanupHandler import com.sameerasw.essentials.services.handlers.AppVolumeOverlayHandler import com.sameerasw.essentials.services.handlers.StatusBarIconHandler @@ -90,6 +91,7 @@ class ScreenOffAccessibilityService : lateinit var islandOverlayHandler: IslandCoordinator private lateinit var statusGlanceHandler: StatusGlanceHandler private lateinit var screenshotShareCleanupHandler: ScreenshotShareCleanupHandler + private lateinit var screenshotAutoCleanHandler: ScreenshotAutoCleanHandler private lateinit var lockscreenShortcutsHandler: LockscreenShortcutsHandler private lateinit var appVolumeOverlayHandler: AppVolumeOverlayHandler @@ -343,9 +345,11 @@ class ScreenOffAccessibilityService : lockscreenShortcutsHandler = LockscreenShortcutsHandler(this, flashlightHandler) appVolumeOverlayHandler = AppVolumeOverlayHandler(this) screenshotShareCleanupHandler = ScreenshotShareCleanupHandler(this, serviceScope) + screenshotAutoCleanHandler = ScreenshotAutoCleanHandler(this, serviceScope) flashlightHandler.register() statusBarIconHandler.register() + screenshotAutoCleanHandler.register() smartPixelsHandler.init() duoOverlayHandler.init() statusGlanceHandler.init() @@ -562,6 +566,7 @@ class ScreenOffAccessibilityService : lockscreenShortcutsHandler.onDestroy() appVolumeOverlayHandler.onDestroy() statusBarIconHandler.unregister() + screenshotAutoCleanHandler.unregister() stopInputEventListener() cancelPocketFlashlightTurnOff() if (isProximityRegistered) { diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt index 9df16ed07..062292c61 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt @@ -67,6 +67,7 @@ import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet import com.sameerasw.essentials.ui.features.battery.BatteriesSettingsUI import com.sameerasw.essentials.ui.features.consciousgate.CONSCIOUS_GATE_FEATURE_ID import com.sameerasw.essentials.ui.features.security.AppLockSettingsUI +import com.sameerasw.essentials.ui.features.security.ScreenshotsSettingsUI import com.sameerasw.essentials.ui.features.system.AlwaysOnDisplaySettingsUI import com.sameerasw.essentials.ui.features.system.BACK_GESTURE_FEATURE_ID import com.sameerasw.essentials.ui.features.system.BackGestureSettingsUI @@ -381,6 +382,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Notification Sync" -> !isNotificationListenerEnabled "Button remap" -> !isAccessibilityEnabled "Face unlock brightness" -> !isAccessibilityEnabled + "Screenshots" -> !isAccessibilityEnabled || !viewModel.isStoragePermissionGranted.value "Lock screen shortcuts" -> !isAccessibilityEnabled "Volume steps" -> !isAccessibilityEnabled "App volume" -> !isAccessibilityEnabled || !isShizukuPermissionGranted @@ -785,7 +787,7 @@ class FeatureSettingsActivity : AppCompatActivity() { listOf( "Lockdown mode", "Face unlock brightness", - "Screenshot share cleanup", + "Screenshots", ), ) @@ -840,6 +842,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Hilight", "Hilight notifications", "Hilight live progress" -> !isNotificationListenerEnabled || !isShizukuPermissionGranted "Button remap" -> !isAccessibilityEnabled "Face unlock brightness" -> !isAccessibilityEnabled + "Screenshots" -> !isAccessibilityEnabled || !viewModel.isStoragePermissionGranted.value "Lock screen shortcuts" -> !isAccessibilityEnabled "Volume steps" -> !isAccessibilityEnabled "App volume" -> !isAccessibilityEnabled || !isShizukuPermissionGranted @@ -1175,6 +1178,14 @@ class FeatureSettingsActivity : AppCompatActivity() { ) } + "Screenshots" -> { + ScreenshotsSettingsUI( + viewModel = viewModel, + modifier = Modifier.padding(top = 16.dp), + highlightSetting = highlightSetting, + ) + } + "Pocket mode" -> { PocketModeSettingsUI( viewModel = viewModel, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/security/ScreenshotsSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/security/ScreenshotsSettingsUI.kt new file mode 100644 index 000000000..3dca043f9 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/security/ScreenshotsSettingsUI.kt @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Security + * File: ScreenshotsSettingsUI.kt + * Description: Settings for cleaning up shared and old screenshots. + */ + +package com.sameerasw.essentials.ui.features.security + +import android.text.format.Formatter +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.ui.core.cards.IconToggleItem +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.core.pickers.SegmentedPicker +import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet +import com.sameerasw.essentials.ui.modifiers.highlight +import com.sameerasw.essentials.utils.HapticUtil +import com.sameerasw.essentials.utils.PermissionUIHelper +import com.sameerasw.essentials.viewmodels.MainViewModel + +private val SHARE_DELAY_MINUTES = listOf(0, 60, 24 * 60) +private val AUTO_CLEAN_DAYS = listOf(1, 7, 30) + +@Composable +fun ScreenshotsSettingsUI( + viewModel: MainViewModel, + modifier: Modifier = Modifier, + highlightSetting: String? = null, +) { + val context = LocalContext.current + val view = LocalView.current + var requestingPermissionsFor by remember { mutableStateOf?>(null) } + + fun missingPermissions(needsNotifications: Boolean): List = + buildList { + if (!viewModel.isAccessibilityEnabled.value) add("ACCESSIBILITY") + if (!viewModel.isStoragePermissionGranted.value) add("STORAGE") + if (needsNotifications && !viewModel.isPostNotificationsEnabled.value) add("POST_NOTIFICATIONS") + } + + requestingPermissionsFor?.let { permKeys -> + PermissionsBottomSheet( + onDismissRequest = { + requestingPermissionsFor = null + viewModel.check(context) + }, + featureTitle = stringResource(R.string.feat_screenshots_title), + permissions = PermissionUIHelper.getPermissionItems(permKeys, context, viewModel), + ) + } + + Column( + modifier = + modifier + .fillMaxWidth() + .padding(16.dp), + ) { + SectionTitle(R.string.screenshots_section_after_sharing) + RoundedCardContainer( + spacing = 2.dp, + cornerRadius = 24.dp, + ) { + IconToggleItem( + iconRes = R.drawable.rounded_share_24, + title = stringResource(R.string.feat_screenshot_share_cleanup_title), + description = stringResource(R.string.feat_screenshot_share_cleanup_desc), + isChecked = viewModel.isScreenshotShareCleanupEnabled.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + val missing = missingPermissions(needsNotifications = false) + if (checked && missing.isNotEmpty()) { + requestingPermissionsFor = missing + } else { + viewModel.setScreenshotShareCleanupEnabled(checked) + } + }, + modifier = Modifier.highlight(highlightSetting == "screenshot_share_cleanup_enabled"), + ) + + AnimatedVisibility( + visible = viewModel.isScreenshotShareCleanupEnabled.value, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + PickerRow( + title = R.string.screenshot_share_cleanup_delay_title, + highlighted = highlightSetting == "screenshot_share_cleanup_delay", + ) { + SegmentedPicker( + items = SHARE_DELAY_MINUTES, + selectedItem = viewModel.screenshotShareCleanupDelayMinutes.intValue, + onItemSelected = { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setScreenshotShareCleanupDelayMinutes(it) + }, + labelProvider = { minutes -> + when (minutes) { + 0 -> context.getString(R.string.screenshot_delay_immediately) + 60 -> context.getString(R.string.screenshot_delay_1_hour) + else -> context.getString(R.string.screenshot_delay_1_day) + } + }, + title = R.string.screenshot_share_cleanup_delay_title, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + SectionTitle(R.string.screenshots_section_auto_clean) + RoundedCardContainer( + spacing = 2.dp, + cornerRadius = 24.dp, + ) { + IconToggleItem( + iconRes = R.drawable.rounded_timer_24, + title = stringResource(R.string.screenshot_auto_clean_title), + description = stringResource(R.string.screenshot_auto_clean_note), + isChecked = viewModel.isScreenshotAutoCleanEnabled.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + val missing = missingPermissions(needsNotifications = true) + if (checked && missing.isNotEmpty()) { + requestingPermissionsFor = missing + } else { + viewModel.setScreenshotAutoCleanEnabled(checked) + } + }, + modifier = Modifier.highlight(highlightSetting == "screenshot_auto_clean_enabled"), + ) + + AnimatedVisibility( + visible = viewModel.isScreenshotAutoCleanEnabled.value, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + PickerRow( + title = R.string.screenshot_auto_clean_days_title, + highlighted = highlightSetting == "screenshot_auto_clean_days", + ) { + SegmentedPicker( + items = AUTO_CLEAN_DAYS, + selectedItem = viewModel.screenshotAutoCleanDays.intValue, + onItemSelected = { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setScreenshotAutoCleanDays(it) + }, + labelProvider = { days -> context.resources.getQuantityString(R.plurals.screenshot_days, days, days) }, + title = R.string.screenshot_auto_clean_days_title, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + SectionTitle(R.string.screenshots_section_stats) + RoundedCardContainer( + spacing = 2.dp, + cornerRadius = 24.dp, + ) { + IconToggleItem( + iconRes = R.drawable.rounded_delete_24, + title = stringResource(R.string.screenshots_cleaned_count_title), + description = viewModel.screenshotsCleanedCount.intValue.toString(), + showToggle = false, + ) + IconToggleItem( + iconRes = R.drawable.rounded_save_24, + title = stringResource(R.string.screenshots_storage_saved_title), + description = Formatter.formatShortFileSize(context, viewModel.screenshotsCleanedBytes.longValue), + showToggle = false, + ) + } + } +} + +@Composable +private fun SectionTitle(title: Int) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp), + ) +} + +@Composable +private fun PickerRow( + title: Int, + highlighted: Boolean, + content: @Composable () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.extraSmall) + .padding(top = 12.dp) + .highlight(highlighted), + ) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 16.dp), + ) + content() + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleaner.kt b/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleaner.kt new file mode 100644 index 000000000..202a7f0c3 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleaner.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Utilities - Screenshots + * File: ScreenshotCleaner.kt + * Description: Finds screenshots in MediaStore, moves them to the trash and schedules delayed cleanups. + */ + +package com.sameerasw.essentials.utils + +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.services.ScreenshotTrashWorker +import java.util.concurrent.TimeUnit + +object ScreenshotCleaner { + const val TAG_SHARED = "screenshot_cleanup_shared" + const val TAG_AUTO_CLEAN = "screenshot_cleanup_auto" + + private val collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + private val SCREENSHOTS_PATH = "${Environment.DIRECTORY_PICTURES}/${Environment.DIRECTORY_SCREENSHOTS}/" + + fun findScreenshotAddedBetween( + context: Context, + fromSeconds: Long, + toSeconds: Long, + ): Long? = + context.contentResolver + .query( + collection, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.DATE_ADDED} BETWEEN ? AND ?", + arrayOf(SCREENSHOTS_PATH, fromSeconds.toString(), toSeconds.toString()), + "${MediaStore.MediaColumns.DATE_ADDED} DESC", + )?.use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } + + fun screenshotIdsAddedSince( + context: Context, + lastSeenId: Long, + sinceSeconds: Long, + ): List = + context.contentResolver + .query( + collection, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns._ID} > ? AND ${MediaStore.MediaColumns.DATE_ADDED} >= ?", + arrayOf(SCREENSHOTS_PATH, lastSeenId.toString(), sinceSeconds.toString()), + "${MediaStore.MediaColumns._ID} ASC", + )?.use { cursor -> + buildList { while (cursor.moveToNext()) add(cursor.getLong(0)) } + } ?: emptyList() + + fun trash( + context: Context, + mediaId: Long, + keepStarred: Boolean = true, + ): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false + val uri = ContentUris.withAppendedId(collection, mediaId) + val size = + context.contentResolver + .query(uri, arrayOf(MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.IS_FAVORITE), null, null, null) + ?.use { cursor -> if (cursor.moveToFirst() && !(keepStarred && cursor.getInt(1) == 1)) cursor.getLong(0) else null } + ?: return false + val trashed = + runCatching { + val values = ContentValues().apply { put(MediaStore.MediaColumns.IS_TRASHED, 1) } + context.contentResolver.update(uri, values, null) > 0 + }.getOrDefault(false) + if (trashed) SettingsRepository(context).recordScreenshotCleaned(size) + return trashed + } + + fun schedule( + context: Context, + mediaId: Long, + delayMinutes: Long, + tag: String, + ) { + val request = + OneTimeWorkRequestBuilder() + .setInitialDelay(delayMinutes, TimeUnit.MINUTES) + .setInputData(workDataOf(ScreenshotTrashWorker.KEY_MEDIA_ID to mediaId)) + .addTag(tag) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(workName(mediaId), ExistingWorkPolicy.REPLACE, request) + } + + fun cancel( + context: Context, + mediaId: Long, + ) { + WorkManager.getInstance(context).cancelUniqueWork(workName(mediaId)) + } + + fun cancelAll( + context: Context, + tag: String, + ) { + WorkManager.getInstance(context).cancelAllWorkByTag(tag) + } + + private fun workName(mediaId: Long) = "screenshot_trash_$mediaId" +} diff --git a/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleanupNotifier.kt b/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleanupNotifier.kt new file mode 100644 index 000000000..502c2a375 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleanupNotifier.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Utilities - Screenshots + * File: ScreenshotCleanupNotifier.kt + * Description: Quiet notification for a newly scheduled screenshot with Keep and Trash now actions. + */ + +package com.sameerasw.essentials.utils + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.os.Build +import android.provider.MediaStore +import android.util.Size +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.sameerasw.essentials.R +import com.sameerasw.essentials.receivers.ScreenshotCleanupActionReceiver + +object ScreenshotCleanupNotifier { + private const val CHANNEL_ID = "screenshot_cleanup" + private const val NOTIFICATION_TAG = "screenshot_cleanup" + private const val GROUP_KEY = "com.sameerasw.essentials.SCREENSHOT_CLEANUP" + private const val TIMEOUT_MILLIS = 60 * 60 * 1000L + + fun show( + context: Context, + mediaId: Long, + days: Int, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return + } + createChannel(context) + + val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, mediaId) + val openIntent = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "image/*") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val thumbnail = loadThumbnail(context, mediaId) + + val builder = + NotificationCompat + .Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.rounded_screenshot_region_24) + .setContentTitle(context.resources.getQuantityString(R.plurals.screenshot_auto_clean_notification_title, days, days)) + .setContentText(context.getString(R.string.screenshot_auto_clean_notification_text)) + .setContentIntent( + PendingIntent.getActivity(context, requestCode(mediaId, 0), openIntent, PendingIntent.FLAG_IMMUTABLE), + ).addAction( + R.drawable.rounded_bookmark_24, + context.getString(R.string.screenshot_action_keep), + actionIntent(context, mediaId, ScreenshotCleanupActionReceiver.ACTION_KEEP, 1), + ).addAction( + R.drawable.rounded_delete_24, + context.getString(R.string.screenshot_action_trash_now), + actionIntent(context, mediaId, ScreenshotCleanupActionReceiver.ACTION_TRASH_NOW, 2), + ).setSilent(true) + .setAutoCancel(true) + .setGroup(GROUP_KEY) + .setTimeoutAfter(TIMEOUT_MILLIS) + .setPriority(NotificationCompat.PRIORITY_LOW) + if (thumbnail != null) { + builder + .setLargeIcon(thumbnail) + .setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?)) + } + NotificationManagerCompat.from(context).notify(NOTIFICATION_TAG, mediaId.toInt(), builder.build()) + } + + fun dismiss( + context: Context, + mediaId: Long, + ) { + NotificationManagerCompat.from(context).cancel(NOTIFICATION_TAG, mediaId.toInt()) + } + + private fun createChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val channel = + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.screenshot_cleanup_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { setShowBadge(false) } + context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + private fun actionIntent( + context: Context, + mediaId: Long, + action: String, + slot: Int, + ): PendingIntent { + val intent = + Intent(context, ScreenshotCleanupActionReceiver::class.java).apply { + this.action = action + putExtra(ScreenshotCleanupActionReceiver.EXTRA_MEDIA_ID, mediaId) + } + return PendingIntent.getBroadcast( + context, + requestCode(mediaId, slot), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun requestCode( + mediaId: Long, + slot: Int, + ) = (mediaId * 3 + slot).toInt() + + private fun loadThumbnail( + context: Context, + mediaId: Long, + ): Bitmap? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null + val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, mediaId) + return runCatching { context.contentResolver.loadThumbnail(uri, Size(720, 720), null) }.getOrNull() + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index ce41913a1..369c78397 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -87,6 +87,7 @@ import com.sameerasw.essentials.utils.DeviceUtils import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.utils.RefreshRateUtils import com.sameerasw.essentials.utils.RootUtils +import com.sameerasw.essentials.utils.ScreenshotCleaner import com.sameerasw.essentials.utils.PrivilegedMode import com.sameerasw.essentials.utils.ShellUtils import com.sameerasw.essentials.utils.ShizukuUtils @@ -398,6 +399,11 @@ class MainViewModel : ViewModel() { val isPocketModeEnabled = mutableStateOf(false) val isFaceUnlockBrightnessEnabled = mutableStateOf(false) val isScreenshotShareCleanupEnabled = mutableStateOf(false) + val screenshotShareCleanupDelayMinutes = mutableIntStateOf(0) + val isScreenshotAutoCleanEnabled = mutableStateOf(false) + val screenshotAutoCleanDays = mutableIntStateOf(7) + val screenshotsCleanedCount = mutableIntStateOf(0) + val screenshotsCleanedBytes = mutableLongStateOf(0L) val faceUnlockMaxBrightness = mutableIntStateOf(100) val faceUnlockAmbientThreshold = mutableFloatStateOf(10f) val isFaceUnlockTriggerUnlock = mutableStateOf(true) @@ -1387,6 +1393,21 @@ class MainViewModel : ViewModel() { SettingsRepository.KEY_SCREENSHOT_SHARE_CLEANUP_ENABLED -> isScreenshotShareCleanupEnabled.value = settingsRepository.isScreenshotShareCleanupEnabled() + SettingsRepository.KEY_SCREENSHOT_SHARE_CLEANUP_DELAY -> + screenshotShareCleanupDelayMinutes.intValue = settingsRepository.getScreenshotShareCleanupDelayMinutes() + + SettingsRepository.KEY_SCREENSHOT_AUTO_CLEAN_ENABLED -> + isScreenshotAutoCleanEnabled.value = settingsRepository.isScreenshotAutoCleanEnabled() + + SettingsRepository.KEY_SCREENSHOT_AUTO_CLEAN_DAYS -> + screenshotAutoCleanDays.intValue = settingsRepository.getScreenshotAutoCleanDays() + + SettingsRepository.KEY_SCREENSHOTS_CLEANED_COUNT -> + screenshotsCleanedCount.intValue = settingsRepository.getScreenshotsCleanedCount() + + SettingsRepository.KEY_SCREENSHOTS_CLEANED_BYTES -> + screenshotsCleanedBytes.longValue = settingsRepository.getScreenshotsCleanedBytes() + SettingsRepository.KEY_FACE_UNLOCK_MAX_BRIGHTNESS -> faceUnlockMaxBrightness.intValue = settingsRepository.getFaceUnlockMaxBrightness() @@ -2826,6 +2847,11 @@ class MainViewModel : ViewModel() { settingsRepository.getBoolean(SettingsRepository.KEY_POCKET_MODE_ENABLED) isFaceUnlockBrightnessEnabled.value = settingsRepository.isFaceUnlockBrightnessEnabled() isScreenshotShareCleanupEnabled.value = settingsRepository.isScreenshotShareCleanupEnabled() + screenshotShareCleanupDelayMinutes.intValue = settingsRepository.getScreenshotShareCleanupDelayMinutes() + isScreenshotAutoCleanEnabled.value = settingsRepository.isScreenshotAutoCleanEnabled() + screenshotAutoCleanDays.intValue = settingsRepository.getScreenshotAutoCleanDays() + screenshotsCleanedCount.intValue = settingsRepository.getScreenshotsCleanedCount() + screenshotsCleanedBytes.longValue = settingsRepository.getScreenshotsCleanedBytes() faceUnlockMaxBrightness.intValue = settingsRepository.getFaceUnlockMaxBrightness() faceUnlockAmbientThreshold.floatValue = settingsRepository.getFaceUnlockAmbientThreshold() isFaceUnlockTriggerUnlock.value = settingsRepository.isFaceUnlockTriggerUnlockEnabled() @@ -9608,6 +9634,23 @@ class MainViewModel : ViewModel() { fun setScreenshotShareCleanupEnabled(enabled: Boolean) { settingsRepository.setScreenshotShareCleanupEnabled(enabled) isScreenshotShareCleanupEnabled.value = enabled + if (!enabled) appContext?.let { ScreenshotCleaner.cancelAll(it, ScreenshotCleaner.TAG_SHARED) } + } + + fun setScreenshotShareCleanupDelayMinutes(minutes: Int) { + settingsRepository.setScreenshotShareCleanupDelayMinutes(minutes) + screenshotShareCleanupDelayMinutes.intValue = minutes + } + + fun setScreenshotAutoCleanEnabled(enabled: Boolean) { + settingsRepository.setScreenshotAutoCleanEnabled(enabled) + isScreenshotAutoCleanEnabled.value = enabled + if (!enabled) appContext?.let { ScreenshotCleaner.cancelAll(it, ScreenshotCleaner.TAG_AUTO_CLEAN) } + } + + fun setScreenshotAutoCleanDays(days: Int) { + settingsRepository.setScreenshotAutoCleanDays(days) + screenshotAutoCleanDays.intValue = days } fun setFaceUnlockAmbientThreshold(value: Float) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c5138d15..78c3ca645 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3362,6 +3362,36 @@ Trash screenshots after sharing After you share a screenshot from the screenshot preview, it is moved to the trash a minute after you leave the app you shared it to. Trashed screenshots can be restored from Files or Photos for 30 days. Shared screenshot moved to trash + Shared screenshot will be moved to trash later + Screenshots + Clean up shared and old screenshots + Keeps your gallery free of one-off screenshots. Shared screenshots can be trashed right after sharing or a little later, and every new screenshot can be trashed automatically after a few days unless you keep it from its notification or star it in Photos. Trashed screenshots can be restored from Files or Photos for 30 days. + After sharing + Auto-clean + Cleaned so far + Trash after sharing + Immediately + 1 hour + 1 day + Auto-clean screenshots + Trash new screenshots after + Each new screenshot gets a quiet notification to keep it. Starred screenshots are always kept. + Screenshots cleaned + Storage saved + Tap Keep or star it in Photos to save it + Keep + Trash now + Screenshot kept + Screenshot moved to trash + Screenshot cleanup + + %d day + %d days + + + Screenshot will be trashed in %d day + Screenshot will be trashed in %d days + Illuminate Ambient threshold Now: %1$s From bd4e97d5b3a03f261ee582c8c228a18822450116 Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Thu, 15 Oct 2026 11:48:36 -0700 Subject: [PATCH 04/12] feat(island): add navigation plugin and settings to island --- .../data/repository/SettingsRepository.kt | 6 +- .../model/NavigationNotificationData.kt | 26 ++-- .../plugins/navigation/NavigationPlugin.kt | 127 ++++++++++-------- .../services/NotificationListener.kt | 41 +++--- .../ui/features/display/IslandSettingsUI.kt | 7 +- .../IslandNavigationOptionsBottomSheet.kt | 33 ++--- .../essentials/viewmodels/MainViewModel.kt | 33 +++++ .../essentials/domain/NavigationParserTest.kt | 16 ++- .../navigation/NavigationTriggerTest.kt | 78 +++++++++++ 9 files changed, 252 insertions(+), 115 deletions(-) create mode 100644 app/src/test/java/com/sameerasw/essentials/island/plugins/navigation/NavigationTriggerTest.kt diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 90d2dbed7..c6cca601b 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -4073,15 +4073,13 @@ class SettingsRepository( fun isIslandShowNavigationEnabled(): Boolean = getBoolean(KEY_ISLAND_SHOW_NAVIGATION, true) fun setIslandShowNavigationEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_SHOW_NAVIGATION, enabled) - // What the island does when a new instruction arrives: expand, peek or off fun getIslandNavigationOnNew(): String = - getString(KEY_ISLAND_NAVIGATION_ON_NEW, ISLAND_NAVIGATION_ON_NEW_PEEK) ?: ISLAND_NAVIGATION_ON_NEW_PEEK + getString(KEY_ISLAND_NAVIGATION_ON_NEW, ISLAND_NAVIGATION_ON_NEW_EXPAND) ?: ISLAND_NAVIGATION_ON_NEW_EXPAND fun setIslandNavigationOnNew(mode: String) = putString(KEY_ISLAND_NAVIGATION_ON_NEW, mode) - fun isIslandNavigationPeekAfterExpandEnabled(): Boolean = getBoolean(KEY_ISLAND_NAVIGATION_PEEK_AFTER_EXPAND, true) + fun isIslandNavigationPeekAfterExpandEnabled(): Boolean = getBoolean(KEY_ISLAND_NAVIGATION_PEEK_AFTER_EXPAND, false) fun setIslandNavigationPeekAfterExpandEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_NAVIGATION_PEEK_AFTER_EXPAND, enabled) - // Meters before a turn at which the island expands; 0 = off fun getIslandNavigationExpandDistance(): Int = getInt(KEY_ISLAND_NAVIGATION_EXPAND_DISTANCE, 200) fun setIslandNavigationExpandDistance(meters: Int) = putInt(KEY_ISLAND_NAVIGATION_EXPAND_DISTANCE, meters) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/NavigationNotificationData.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/NavigationNotificationData.kt index 1916b2592..fa9dd97da 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/NavigationNotificationData.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/NavigationNotificationData.kt @@ -9,7 +9,6 @@ data class NavigationNotificationData( val instruction: String?, val distance: String?, val eta: String?, - // Turn arrow, usually the notification's large icon. val maneuver: Bitmap?, val contentIntent: PendingIntent?, val exitIntent: PendingIntent?, @@ -19,7 +18,13 @@ data class NavigationNotificationData( object NavigationParser { data class Parsed(val instruction: String?, val distance: String?, val eta: String?) - private val distanceRegex = Regex("""\d[\d.,]*\s?(km|m|mi|ft|yd)\b""", RegexOption.IGNORE_CASE) + private val unitMeters = mapOf( + "km" to 1000.0, "m" to 1.0, "mi" to 1609.344, "ft" to 0.3048, "yd" to 0.9144, + "км" to 1000.0, "м" to 1.0, + "公里" to 1000.0, "千米" to 1000.0, "公尺" to 1.0, "米" to 1.0, + ) + private val units = unitMeters.keys.sortedByDescending { it.length }.joinToString("|") + private val distanceRegex = Regex("""\d[\d.,]*\s?($units)(?!\p{L})""", RegexOption.IGNORE_CASE) private val exitRegex = Regex( """\b(exit|stop|end|close|quit|salir|terminar|detener|finalizar|beenden|quitter|arrêter|sair|encerrar)\b""", RegexOption.IGNORE_CASE, @@ -27,8 +32,6 @@ object NavigationParser { fun isDistance(s: String?) = s != null && s.length <= 24 && distanceRegex.containsMatchIn(s) - // Apps put the distance in either the title or the text, so take whichever looks like one. - // `chip` is the Live Update status-bar text, which navigation apps fill with the distance. fun parse(title: String?, text: String?, subText: String?, chip: String?): Parsed { val titleIsDistance = isDistance(title) val textIsDistance = !titleIsDistance && isDistance(text) @@ -50,10 +53,8 @@ object NavigationParser { ) } - private val meterRegex = Regex("""(\d{1,3}(?:,\d{3})+|\d+(?:[.,]\d+)?)\s?(km|m|mi|ft|yd)\b""", RegexOption.IGNORE_CASE) - private val unitMeters = mapOf("km" to 1000.0, "m" to 1.0, "mi" to 1609.344, "ft" to 0.3048, "yd" to 0.9144) + private val meterRegex = Regex("""(\d{1,3}(?:,\d{3})+|\d+(?:[.,]\d+)?)\s?($units)(?!\p{L})""", RegexOption.IGNORE_CASE) - // "1,200 ft" uses a thousands separator; "1,2 km" uses a decimal comma. fun meters(distance: String?): Double? { val match = meterRegex.find(distance ?: return null) ?: return null val (number, unit) = match.destructured @@ -61,13 +62,6 @@ object NavigationParser { return value.toDoubleOrNull()?.times(unitMeters.getValue(unit.lowercase())) } - // ponytail: matches the button label; an app with several unlabeled-looking actions gets no Exit button - fun exitActionIndex(titles: List): Int { - val match = titles.indexOfFirst { it != null && exitRegex.containsMatchIn(it) } - return when { - match >= 0 -> match - titles.size == 1 -> 0 - else -> -1 - } - } + fun exitActionIndex(titles: List): Int = + titles.indexOfFirst { it != null && exitRegex.containsMatchIn(it) } } diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationPlugin.kt index c577b7099..8ecb378b6 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationPlugin.kt @@ -36,16 +36,9 @@ class NavigationPlugin : BaseIslandPlugin() { private var data: NavigationNotificationData? = null - private val expandedFor = HashSet() + private val trigger = NavigationTrigger() - private val surfaced = LinkedHashSet() - private var lastSurfacedAt = 0L - - private val endRoute = Runnable { - expandedFor.clear() - surfaced.clear() - lastSurfacedAt = 0L - } + private val endRoute = Runnable { trigger.reset() } private val endExpand = Runnable { val c = ctx ?: return@Runnable @@ -99,54 +92,30 @@ class NavigationPlugin : BaseIslandPlugin() { } c.mainHandler.removeCallbacks(endRoute) if (!enabled()) return - val instruction = next.instruction.orEmpty() - val threshold = settings.getIslandNavigationExpandDistance() - val meters = NavigationParser.meters(next.distance) - // Distance jumped back up: a new maneuver with the same text ("Turn right" twice in a row) - if (meters != null && meters > threshold * REARM_FACTOR) { - expandedFor.remove(instruction) - surfaced.remove(instruction) - } - val approaching = - threshold > 0 && meters != null && meters <= threshold && instruction !in expandedFor - val expandSoon = threshold > 0 && meters != null && meters <= threshold * REARM_FACTOR - val now = System.currentTimeMillis() - val announce = - turned && instruction !in surfaced && !expandSoon && now - lastSurfacedAt >= COOLDOWN_MS - - when { - approaching && expand(c) -> { - expandedFor += instruction - markSurfaced(instruction, now) - } - - !announce -> {} - settings.getIslandNavigationOnNew() == SettingsRepository.ISLAND_NAVIGATION_ON_NEW_EXPAND -> { - if (expand(c)) markSurfaced(instruction, now) - } - - settings.getIslandNavigationOnNew() == SettingsRepository.ISLAND_NAVIGATION_ON_NEW_PEEK && settings.isIslandLineStageEnabled() -> { - c.request(PluginRequest.Peek(ITEM_KEY, settings.getIslandPeekDurationMs())) - markSurfaced(instruction, now) - } + val focused = c.focusedKey() + val action = trigger.decide( + instruction = next.instruction.orEmpty(), + turned = turned, + meters = NavigationParser.meters(next.distance), + threshold = settings.getIslandNavigationExpandDistance(), + onNew = settings.getIslandNavigationOnNew(), + canPeek = settings.isIslandLineStageEnabled(), + canExpand = focused == null || focused == ITEM_KEY, + now = System.currentTimeMillis(), + ) + when (action) { + NavigationTrigger.Action.Expand -> expand(c) + NavigationTrigger.Action.Peek -> c.request(PluginRequest.Peek(ITEM_KEY, settings.getIslandPeekDurationMs())) + NavigationTrigger.Action.None -> {} } } - private fun markSurfaced(instruction: String, now: Long) { - surfaced += instruction - if (surfaced.size > MAX_SURFACED) surfaced.remove(surfaced.first()) - lastSurfacedAt = now - } - - private fun expand(c: IslandPluginContext): Boolean { - val focused = c.focusedKey() - if (focused != null && focused != ITEM_KEY) return false - if (focused == ITEM_KEY && c.currentStage() == IslandStage.Expanded) return true + private fun expand(c: IslandPluginContext) { + if (c.focusedKey() == ITEM_KEY && c.currentStage() == IslandStage.Expanded) return c.mainHandler.removeCallbacks(peekAfterCollapse) c.request(PluginRequest.Expand(ITEM_KEY)) c.mainHandler.removeCallbacks(endExpand) c.mainHandler.postDelayed(endExpand, EXPANDED_MS) - return true } private fun enabled() = settings.isIslandShowNavigationEnabled() @@ -202,10 +171,7 @@ class NavigationPlugin : BaseIslandPlugin() { private const val EXPANDED_MS = 6_000L private const val COLLAPSE_ANIM_MS = 600L - private const val REARM_FACTOR = 1.5 - private const val COOLDOWN_MS = 30_000L private const val ROUTE_GRACE_MS = 60_000L - private const val MAX_SURFACED = 50 } } @@ -221,3 +187,58 @@ internal fun Maneuver(d: NavigationNotificationData, size: Int) { ) } } + +internal class NavigationTrigger { + enum class Action { None, Expand, Peek } + + private val expandedFor = HashSet() + private val surfaced = LinkedHashSet() + private var lastSurfacedAt = 0L + + fun reset() { + expandedFor.clear() + surfaced.clear() + lastSurfacedAt = 0L + } + + fun decide( + instruction: String, + turned: Boolean, + meters: Double?, + threshold: Int, + onNew: String, + canPeek: Boolean, + canExpand: Boolean, + now: Long, + ): Action { + if (meters != null && meters > threshold * REARM_FACTOR) { + expandedFor.remove(instruction) + surfaced.remove(instruction) + } + val approaching = threshold > 0 && meters != null && meters <= threshold && instruction !in expandedFor + val expandSoon = threshold > 0 && meters != null && meters <= threshold * REARM_FACTOR + val announce = turned && instruction !in surfaced && !expandSoon && now - lastSurfacedAt >= COOLDOWN_MS + val action = when { + approaching && canExpand -> { + expandedFor += instruction + Action.Expand + } + !announce -> Action.None + onNew == SettingsRepository.ISLAND_NAVIGATION_ON_NEW_EXPAND && canExpand -> Action.Expand + onNew == SettingsRepository.ISLAND_NAVIGATION_ON_NEW_PEEK && canPeek -> Action.Peek + else -> Action.None + } + if (action != Action.None) { + surfaced += instruction + if (surfaced.size > MAX_SURFACED) surfaced.remove(surfaced.first()) + lastSurfacedAt = now + } + return action + } + + companion object { + const val REARM_FACTOR = 1.5 + const val COOLDOWN_MS = 30_000L + private const val MAX_SURFACED = 50 + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt index b7ec5962c..92c1f4bbd 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt @@ -1048,7 +1048,6 @@ class NotificationListener : NotificationListenerService() { if (extras != null && (extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0) > 0 || extras.containsKey(Notification.EXTRA_PROGRESS_INDETERMINATE))) { scheduleProgressRefresh() } else if (sbn.isOngoing && isNavigationNotification(sbn)) { - // Navigation shares the progress refresh pass scheduleProgressRefresh() } else if (HilightProgressTracker.isTracking(sbn.key)) { // Its progress bar was replaced, e.g. by "Download complete" @@ -1897,6 +1896,14 @@ class NotificationListener : NotificationListenerService() { private val progressRefreshRunnable = Runnable { progressExecutor.execute { + val navigation = + try { + extractLatestNavigationNotification() + } catch (e: Exception) { + Log.e("NotificationListener", "Failed to extract navigation notification", e) + null + } + progressHandler.post { notifyNavigationListeners(navigation) } val data = try { extractLatestProgressNotification() @@ -1905,17 +1912,7 @@ class NotificationListener : NotificationListenerService() { return@execute } HilightProgressTracker.onProgress(applicationContext, data) - val navigation = - try { - extractLatestNavigationNotification() - } catch (e: Exception) { - Log.e("NotificationListener", "Failed to extract navigation notification", e) - null - } - progressHandler.post { - notifyProgressListeners(data) - notifyNavigationListeners(navigation) - } + progressHandler.post { notifyProgressListeners(data) } } } @@ -1952,13 +1949,15 @@ class NotificationListener : NotificationListenerService() { return progressNotifs.maxByOrNull { it.postTime } } - // The island's navigation item replaces the progress ring for these private val islandShowsNavigation: Boolean get() = getSharedPreferences(SettingsRepository.PREFS_NAME, MODE_PRIVATE).let { it.getBoolean(SettingsRepository.KEY_ISLAND_ENABLED, false) && it.getBoolean(SettingsRepository.KEY_ISLAND_SHOW_NAVIGATION, true) } + @Volatile + private var navigationManeuverCache: Triple? = null + fun extractLatestNavigationNotification(): NavigationNotificationData? { val active = (safeActiveNotifications() ?: return null).toList() + LiveUpdateSnoozer.snoozedNotifications() return active @@ -1976,10 +1975,16 @@ class NotificationListener : NotificationListenerService() { subText = extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.toString(), chip = extras.getCharSequence(EXTRA_SHORT_CRITICAL_TEXT)?.toString(), ) - val maneuver = try { - notif.getLargeIcon()?.loadDrawable(this)?.let { AppUtil.drawableToBitmap(it) } - } catch (_: Exception) { - null + val postTime = if (sbn.postTime > 0) sbn.postTime else notif.`when` + val cached = navigationManeuverCache + val maneuver = if (cached != null && cached.first == sbn.key && cached.second == postTime) { + cached.third + } else { + try { + notif.getLargeIcon()?.loadDrawable(this)?.let { AppUtil.drawableToBitmap(it) } + } catch (_: Exception) { + null + }.also { navigationManeuverCache = Triple(sbn.key, postTime, it) } } val actions = notif.actions.orEmpty() val exit = NavigationParser.exitActionIndex(actions.map { it.title?.toString() }) @@ -1992,7 +1997,7 @@ class NotificationListener : NotificationListenerService() { maneuver = maneuver, contentIntent = notif.contentIntent, exitIntent = actions.getOrNull(exit)?.actionIntent, - postTime = if (sbn.postTime > 0) sbn.postTime else notif.`when`, + postTime = postTime, ) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt index b4cf28bdb..01211468a 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt @@ -74,10 +74,12 @@ import com.sameerasw.essentials.ui.features.display.actions.HorizontalSlideModeS import com.sameerasw.essentials.ui.features.display.actions.horizontalSlideDescription import com.sameerasw.essentials.ui.features.display.sheets.IslandAlarmOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandBatteryOptionsBottomSheet +import com.sameerasw.essentials.ui.features.display.sheets.IslandBorderOutlineOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandBriefOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandAppOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandCallOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandDevicesBatteryBottomSheet +import com.sameerasw.essentials.ui.features.display.sheets.IslandPulseShadowOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandNavigationOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandSoundModeOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.IslandTimeOptionsBottomSheet @@ -803,7 +805,10 @@ fun IslandSettingsUI( } if (showNavigationOptionsSheet) { - IslandNavigationOptionsBottomSheet(onDismissRequest = { showNavigationOptionsSheet = false }) + IslandNavigationOptionsBottomSheet( + viewModel = viewModel, + onDismissRequest = { showNavigationOptionsSheet = false }, + ) } if (showAlarmOptionsSheet) { diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNavigationOptionsBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNavigationOptionsBottomSheet.kt index 432ed257d..7f15483f5 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNavigationOptionsBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNavigationOptionsBottomSheet.kt @@ -9,12 +9,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -27,18 +22,17 @@ import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.pickers.SegmentedPicker import com.sameerasw.essentials.ui.core.sheets.EssentialsBottomSheet import com.sameerasw.essentials.utils.HapticUtil +import com.sameerasw.essentials.viewmodels.MainViewModel private val EXPAND_DISTANCE_OPTIONS = listOf(0, 50, 100, 200, 500) @OptIn(ExperimentalMaterial3Api::class) @Composable -fun IslandNavigationOptionsBottomSheet(onDismissRequest: () -> Unit) { - val context = LocalContext.current +fun IslandNavigationOptionsBottomSheet( + viewModel: MainViewModel, + onDismissRequest: () -> Unit, +) { val view = LocalView.current - val settings = remember { SettingsRepository(context) } - var onNew by remember { mutableStateOf(settings.getIslandNavigationOnNew()) } - var expandDistance by remember { mutableStateOf(settings.getIslandNavigationExpandDistance()) } - var peekAfterExpand by remember { mutableStateOf(settings.isIslandNavigationPeekAfterExpandEnabled()) } @Composable fun distanceLabel(meters: Int) = @@ -85,11 +79,8 @@ fun IslandNavigationOptionsBottomSheet(onDismissRequest: () -> Unit) { ) SegmentedPicker( items = labels.keys.toList(), - selectedItem = onNew, - onItemSelected = { - onNew = it - settings.setIslandNavigationOnNew(it) - }, + selectedItem = viewModel.islandNavigationOnNew.value, + onItemSelected = { viewModel.setIslandNavigationOnNew(it) }, labelProvider = { labels[it].orEmpty() }, title = R.string.island_navigation_on_new_title, modifier = Modifier.fillMaxWidth(), @@ -99,7 +90,7 @@ fun IslandNavigationOptionsBottomSheet(onDismissRequest: () -> Unit) { ConfigPickerItem( title = stringResource(R.string.island_navigation_expand_title), description = stringResource(R.string.island_navigation_expand_desc), - selectedValue = distanceLabel(expandDistance), + selectedValue = distanceLabel(viewModel.islandNavigationExpandDistance.intValue), iconRes = R.drawable.rounded_magnify_fullscreen_24, modifier = Modifier.fillMaxWidth(), ) { @@ -108,8 +99,7 @@ fun IslandNavigationOptionsBottomSheet(onDismissRequest: () -> Unit) { text = { Text(distanceLabel(meters)) }, onClick = { HapticUtil.performVirtualKeyHaptic(view) - expandDistance = meters - settings.setIslandNavigationExpandDistance(meters) + viewModel.setIslandNavigationExpandDistance(meters) }, ) } @@ -119,11 +109,10 @@ fun IslandNavigationOptionsBottomSheet(onDismissRequest: () -> Unit) { iconRes = R.drawable.round_navigation_24, title = stringResource(R.string.island_navigation_peek_after_expand_title), description = stringResource(R.string.island_navigation_peek_after_expand_desc), - isChecked = peekAfterExpand, + isChecked = viewModel.isIslandNavigationPeekAfterExpand.value, onCheckedChange = { HapticUtil.performVirtualKeyHaptic(view) - peekAfterExpand = it - settings.setIslandNavigationPeekAfterExpandEnabled(it) + viewModel.setIslandNavigationPeekAfterExpand(it) }, ) } diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index a595a2904..a3a58981b 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -242,6 +242,9 @@ class MainViewModel : ViewModel() { val isIslandShowTravel = mutableStateOf(true) val isIslandShowCaffeinate = mutableStateOf(true) val isIslandShowNavigation = mutableStateOf(true) + val islandNavigationOnNew = mutableStateOf(SettingsRepository.ISLAND_NAVIGATION_ON_NEW_EXPAND) + val islandNavigationExpandDistance = mutableIntStateOf(200) + val isIslandNavigationPeekAfterExpand = mutableStateOf(false) val isIslandShowHotspot = mutableStateOf(false) val isIslandShowDevices = mutableStateOf(true) val isIslandBriefEnabled = mutableStateOf(false) @@ -947,6 +950,18 @@ class MainViewModel : ViewModel() { SettingsRepository.KEY_ISLAND_NOTIF_TAP_TO_OPEN -> isIslandNotifTapToOpen.value = settingsRepository.isIslandNotifTapToOpenEnabled() + SettingsRepository.KEY_ISLAND_SHOW_NAVIGATION -> + isIslandShowNavigation.value = settingsRepository.isIslandShowNavigationEnabled() + + SettingsRepository.KEY_ISLAND_NAVIGATION_ON_NEW -> + islandNavigationOnNew.value = settingsRepository.getIslandNavigationOnNew() + + SettingsRepository.KEY_ISLAND_NAVIGATION_EXPAND_DISTANCE -> + islandNavigationExpandDistance.intValue = settingsRepository.getIslandNavigationExpandDistance() + + SettingsRepository.KEY_ISLAND_NAVIGATION_PEEK_AFTER_EXPAND -> + isIslandNavigationPeekAfterExpand.value = settingsRepository.isIslandNavigationPeekAfterExpandEnabled() + SettingsRepository.KEY_ISLAND_CATCH_UP_ENABLED -> isIslandCatchUpEnabled.value = settingsRepository.isIslandCatchUpEnabled() @@ -2417,6 +2432,9 @@ class MainViewModel : ViewModel() { isDuoDisableInteractionsInFullscreen.value = settingsRepository.isDuoDisableInteractionsInFullscreenEnabled() isIslandShowCaffeinate.value = settingsRepository.isIslandShowCaffeinateEnabled() isIslandShowNavigation.value = settingsRepository.isIslandShowNavigationEnabled() + islandNavigationOnNew.value = settingsRepository.getIslandNavigationOnNew() + islandNavigationExpandDistance.intValue = settingsRepository.getIslandNavigationExpandDistance() + isIslandNavigationPeekAfterExpand.value = settingsRepository.isIslandNavigationPeekAfterExpandEnabled() isIslandShowHotspot.value = settingsRepository.isIslandShowHotspotEnabled() isIslandShowDevices.value = settingsRepository.isIslandShowDevicesEnabled() isIslandBriefEnabled.value = settingsRepository.isIslandBriefEnabled() @@ -5511,6 +5529,21 @@ class MainViewModel : ViewModel() { settingsRepository.setIslandShowNavigationEnabled(enabled) } + fun setIslandNavigationOnNew(mode: String) { + islandNavigationOnNew.value = mode + settingsRepository.setIslandNavigationOnNew(mode) + } + + fun setIslandNavigationExpandDistance(meters: Int) { + islandNavigationExpandDistance.intValue = meters + settingsRepository.setIslandNavigationExpandDistance(meters) + } + + fun setIslandNavigationPeekAfterExpand(enabled: Boolean) { + isIslandNavigationPeekAfterExpand.value = enabled + settingsRepository.setIslandNavigationPeekAfterExpandEnabled(enabled) + } + fun setIslandShowHotspot(enabled: Boolean) { isIslandShowHotspot.value = enabled settingsRepository.setIslandShowHotspotEnabled(enabled) diff --git a/app/src/test/java/com/sameerasw/essentials/domain/NavigationParserTest.kt b/app/src/test/java/com/sameerasw/essentials/domain/NavigationParserTest.kt index cfeb434da..1efbc58c7 100644 --- a/app/src/test/java/com/sameerasw/essentials/domain/NavigationParserTest.kt +++ b/app/src/test/java/com/sameerasw/essentials/domain/NavigationParserTest.kt @@ -38,6 +38,13 @@ class NavigationParserTest { assertEquals("Toward Main St", p.eta) } + @Test + fun nonLatinDistanceInTitle() { + val p = NavigationParser.parse("300 м", "Поверните направо", null, null) + assertEquals("300 м", p.distance) + assertEquals("Поверните направо", p.instruction) + } + @Test fun longTextIsNotADistance() { val p = NavigationParser.parse("Continue on Highway 15D for 25 km", null, null, null) @@ -48,7 +55,8 @@ class NavigationParserTest { fun exitAction() { assertEquals(1, NavigationParser.exitActionIndex(listOf("Mute", "Exit navigation"))) assertEquals(0, NavigationParser.exitActionIndex(listOf("Salir"))) - assertEquals(0, NavigationParser.exitActionIndex(listOf("Something"))) + assertEquals(-1, NavigationParser.exitActionIndex(listOf("Something"))) + assertEquals(-1, NavigationParser.exitActionIndex(listOf("Mute"))) assertEquals(-1, NavigationParser.exitActionIndex(listOf("Mute", "Send"))) assertEquals(-1, NavigationParser.exitActionIndex(emptyList())) } @@ -61,6 +69,12 @@ class NavigationParserTest { assertEquals(0.2 * 1609.344, NavigationParser.meters("0.2 mi")!!, 0.01) assertEquals(300.0, NavigationParser.meters("300 m")!!, 0.01) assertEquals(50.0, NavigationParser.meters("In 50m")!!, 0.01) + assertEquals(300.0, NavigationParser.meters("300 м")!!, 0.01) + assertEquals(1500.0, NavigationParser.meters("1,5 км")!!, 0.01) + assertEquals(300.0, NavigationParser.meters("300米")!!, 0.01) + assertEquals(1200.0, NavigationParser.meters("1.2公里")!!, 0.01) + assertEquals(1200.0, NavigationParser.meters("1.2千米")!!, 0.01) + assertNull(NavigationParser.meters("5 мин")) assertNull(NavigationParser.meters("Western Road")) assertNull(NavigationParser.meters(null)) } diff --git a/app/src/test/java/com/sameerasw/essentials/island/plugins/navigation/NavigationTriggerTest.kt b/app/src/test/java/com/sameerasw/essentials/island/plugins/navigation/NavigationTriggerTest.kt new file mode 100644 index 000000000..026ab6e57 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/island/plugins/navigation/NavigationTriggerTest.kt @@ -0,0 +1,78 @@ +package com.sameerasw.essentials.island.plugins.navigation + +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.island.plugins.navigation.NavigationTrigger.Action +import org.junit.Assert.assertEquals +import org.junit.Test + +class NavigationTriggerTest { + private val trigger = NavigationTrigger() + + private fun decide( + instruction: String = "Turn right", + turned: Boolean = false, + meters: Double? = null, + onNew: String = SettingsRepository.ISLAND_NAVIGATION_ON_NEW_PEEK, + canExpand: Boolean = true, + now: Long = 100_000L, + ) = trigger.decide(instruction, turned, meters, 200, onNew, canPeek = true, canExpand = canExpand, now = now) + + @Test + fun newInstructionPeeks() { + assertEquals(Action.Peek, decide(turned = true, meters = 1000.0)) + } + + @Test + fun newInstructionExpandsWhenSetToExpand() { + assertEquals(Action.Expand, decide(turned = true, meters = 1000.0, onNew = SettingsRepository.ISLAND_NAVIGATION_ON_NEW_EXPAND)) + } + + @Test + fun offDoesNothingOnNewInstruction() { + assertEquals(Action.None, decide(turned = true, meters = 1000.0, onNew = SettingsRepository.ISLAND_NAVIGATION_ON_NEW_OFF)) + } + + @Test + fun announceSkippedWhenTurnIsClose() { + assertEquals(Action.None, decide(turned = true, meters = 250.0)) + } + + @Test + fun approachingExpandsOncePerManeuver() { + assertEquals(Action.Expand, decide(meters = 150.0)) + assertEquals(Action.None, decide(meters = 100.0)) + } + + @Test + fun approachingRearmsAfterDistanceJumpsBack() { + assertEquals(Action.Expand, decide(meters = 150.0)) + decide(meters = 1000.0) + assertEquals(Action.Expand, decide(meters = 150.0)) + } + + @Test + fun approachingWaitsWhileOtherItemIsFocused() { + assertEquals(Action.None, decide(turned = true, meters = 150.0, canExpand = false)) + assertEquals(Action.Expand, decide(meters = 140.0)) + } + + @Test + fun surfacedInstructionNotAnnouncedAgain() { + assertEquals(Action.Peek, decide(turned = true)) + assertEquals(Action.None, decide(turned = true, now = 200_000L)) + } + + @Test + fun cooldownBlocksBackToBackAnnouncements() { + assertEquals(Action.Peek, decide(instruction = "A", turned = true, meters = 1000.0)) + assertEquals(Action.None, decide(instruction = "B", turned = true, meters = 1000.0, now = 110_000L)) + assertEquals(Action.Peek, decide(instruction = "C", turned = true, meters = 1000.0, now = 140_000L)) + } + + @Test + fun resetForgetsSurfaced() { + decide(turned = true, meters = 1000.0) + trigger.reset() + assertEquals(Action.Peek, decide(turned = true, meters = 1000.0)) + } +} From 06206a67e0c812110a5cbacfbc39f3ab53b610f6 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 16 Oct 2026 18:46:17 +0530 Subject: [PATCH 05/12] feat: track seen notifications to prioritize unseen alerts during rotation --- .../notifications/NotificationsPlugin.kt | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt index 006670be7..cc10d9ae4 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt @@ -56,6 +56,7 @@ class NotificationsPlugin : BaseIslandPlugin() { ) private val alerts = ArrayDeque() + private val seen = HashSet() private var currentIndex = 0 private var manualPick = false @@ -100,9 +101,28 @@ class NotificationsPlugin : BaseIslandPlugin() { return alerts.drop(index + 1) + alerts.take(index).asReversed() } + private fun markSeen() { + currentAlert()?.let { seen.add(it.key) } + } + private fun advanceCurrent() { if (alerts.size < 2) return currentIndex = (currentIndex.coerceIn(0, alerts.size - 1) + 1) % alerts.size + markSeen() + } + + private fun advanceToUnseen(): Boolean { + if (alerts.size < 2) return false + val start = currentIndex.coerceIn(0, alerts.size - 1) + for (offset in 1 until alerts.size) { + val index = (start + offset) % alerts.size + if (alerts[index].key !in seen) { + currentIndex = index + markSeen() + return true + } + } + return false } private fun select(key: String) { @@ -113,6 +133,7 @@ class NotificationsPlugin : BaseIslandPlugin() { val here = showingHere() if (here && index == currentIndex) return currentIndex = index + markSeen() manualPick = true pendingPopUp = false scheduleTimeout() @@ -152,6 +173,7 @@ class NotificationsPlugin : BaseIslandPlugin() { } cancelTimers() alerts.clear() + seen.clear() } override fun refresh() { @@ -233,6 +255,7 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun popUp() { val c = ctx ?: return if (minimal()) return + markSeen() val lineAvailable = settings.isIslandLineStageEnabled() && settings.isIslandNotifCompactHeadsUpEnabled() c.request( if (lineAvailable) PluginRequest.Peek(ITEM_KEY, settings.getIslandTimeoutMs(), sticky = true) @@ -243,7 +266,7 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun onRemoved(key: String) { val index = alerts.indexOfFirst { it.key == key } if (index < 0) return - alerts.removeAt(index) + alerts.removeAt(index).also { seen.remove(it.key) } if (index < currentIndex) currentIndex-- currentIndex = currentIndex.coerceIn(0, (alerts.size - 1).coerceAtLeast(0)) if (alerts.isEmpty()) clearAll() else render() @@ -252,8 +275,7 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun onTimeout() { val c = ctx ?: return if (pendingPopUp) return - if (showingHere() && alerts.size > 1 && !manualPick) { - advanceCurrent() + if (showingHere() && alerts.size > 1 && !manualPick && advanceToUnseen()) { scheduleTimeout() render() if (c.currentStage() == IslandStage.Line) popUp() @@ -309,6 +331,7 @@ class NotificationsPlugin : BaseIslandPlugin() { catchingUp = false cancelTimers() alerts.clear() + seen.clear() currentIndex = 0 manualPick = false pendingPopUp = false @@ -394,6 +417,7 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun clearWaiting() { val current = currentAlert() ?: return alerts.removeAll { it !== current } + seen.retainAll { it == current.key } currentIndex = 0 render() } From 981eb037f83c22e564a460a83f50342f22a393a7 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 08:41:19 +0530 Subject: [PATCH 06/12] feat: remove descriptions and update priority of nav plugin --- .../essentials/data/repository/SettingsRepository.kt | 2 +- .../com/sameerasw/essentials/island/model/IslandModels.kt | 4 ++-- .../essentials/island/model/IslandPriorityEntries.kt | 2 +- .../essentials/ui/features/display/IslandSettingsUI.kt | 1 - .../display/sheets/IslandNavigationOptionsBottomSheet.kt | 1 - .../java/com/sameerasw/essentials/viewmodels/MainViewModel.kt | 2 +- app/src/main/res/values/strings.xml | 1 - 7 files changed, 5 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index f8008aa39..0bb1d8e3c 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -4200,7 +4200,7 @@ class SettingsRepository( fun setIslandShowNavigationEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_SHOW_NAVIGATION, enabled) fun getIslandNavigationOnNew(): String = - getString(KEY_ISLAND_NAVIGATION_ON_NEW, ISLAND_NAVIGATION_ON_NEW_EXPAND) ?: ISLAND_NAVIGATION_ON_NEW_EXPAND + getString(KEY_ISLAND_NAVIGATION_ON_NEW, ISLAND_NAVIGATION_ON_NEW_PEEK) ?: ISLAND_NAVIGATION_ON_NEW_PEEK fun setIslandNavigationOnNew(mode: String) = putString(KEY_ISLAND_NAVIGATION_ON_NEW, mode) fun isIslandNavigationPeekAfterExpandEnabled(): Boolean = getBoolean(KEY_ISLAND_NAVIGATION_PEEK_AFTER_EXPAND, false) diff --git a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt index 9f9a3c78a..4441c1e29 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt @@ -21,9 +21,9 @@ object IslandPriority { const val CALENDAR_OVERRIDE = 6 const val NOTIFICATION = 10 - const val NAVIGATION = 14 - const val PROGRESS = 15 + const val PROGRESS = 15 const val FLASHLIGHT = 20 + const val NAVIGATION = 22 const val TIMER = 25 const val MEDIA = 30 const val CONSCIOUS_GATE = 40 diff --git a/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt index 0ccfe2e66..a823c44e5 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt @@ -6,8 +6,8 @@ object IslandPriorityEntries { val all = listOf( Entry("battery", setOf("battery_alerts_plugin", "battery"), IslandPriority.BATTERY_ALERT), Entry("notifications", setOf("notifications"), IslandPriority.NOTIFICATION), - Entry("navigation", setOf("navigation"), IslandPriority.NAVIGATION), Entry("flashlight", setOf("flashlight"), IslandPriority.FLASHLIGHT), + Entry("navigation", setOf("navigation"), IslandPriority.NAVIGATION), Entry("timer", setOf("timer"), IslandPriority.TIMER), Entry("media", setOf("media"), IslandPriority.MEDIA), Entry("conscious_gate", setOf("conscious_gate"), IslandPriority.CONSCIOUS_GATE), diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt index 2df8d419d..9f643d314 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt @@ -437,7 +437,6 @@ fun IslandSettingsUI( dragHandle = handle, iconRes = R.drawable.round_navigation_24, title = stringResource(R.string.island_show_navigation_title), - description = stringResource(R.string.island_navigation_desc), isChecked = viewModel.isIslandShowNavigation.value, onCheckedChange = { checked -> HapticUtil.performVirtualKeyHaptic(view) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNavigationOptionsBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNavigationOptionsBottomSheet.kt index 7f15483f5..12d3edf47 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNavigationOptionsBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNavigationOptionsBottomSheet.kt @@ -89,7 +89,6 @@ fun IslandNavigationOptionsBottomSheet( ConfigPickerItem( title = stringResource(R.string.island_navigation_expand_title), - description = stringResource(R.string.island_navigation_expand_desc), selectedValue = distanceLabel(viewModel.islandNavigationExpandDistance.intValue), iconRes = R.drawable.rounded_magnify_fullscreen_24, modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index be24f4d24..70c165ecf 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -249,7 +249,7 @@ class MainViewModel : ViewModel() { val isIslandShowTravel = mutableStateOf(true) val isIslandShowCaffeinate = mutableStateOf(true) val isIslandShowNavigation = mutableStateOf(true) - val islandNavigationOnNew = mutableStateOf(SettingsRepository.ISLAND_NAVIGATION_ON_NEW_EXPAND) + val islandNavigationOnNew = mutableStateOf(SettingsRepository.ISLAND_NAVIGATION_ON_NEW_PEEK) val islandNavigationExpandDistance = mutableIntStateOf(200) val isIslandNavigationPeekAfterExpand = mutableStateOf(false) val isIslandShowHotspot = mutableStateOf(false) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b61cec239..9bf002961 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3303,7 +3303,6 @@ Expand Line peek Expand before a turn - Expands with the direction when you get this close to the turn Off %d m Line peek after expanding From c5883e91935f4e8583727b077bedf282d14e544a Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 08:54:23 +0530 Subject: [PATCH 07/12] feat: add screenshot cleanup undo support, debounced scanning, and improved trash handling --- .../data/repository/SettingsRepository.kt | 15 +++-- .../ScreenshotCleanupActionReceiver.kt | 48 +++++++++++---- .../services/ScreenshotTrashWorker.kt | 3 +- .../handlers/ScreenshotAutoCleanHandler.kt | 14 ++++- .../handlers/ScreenshotShareCleanupHandler.kt | 2 +- .../essentials/utils/ScreenshotCleaner.kt | 59 +++++++++++++++--- .../utils/ScreenshotCleanupNotifier.kt | 60 ++++++++++++++++--- .../essentials/viewmodels/MainViewModel.kt | 15 ++++- app/src/main/res/values/strings.xml | 4 +- 9 files changed, 178 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 13a9d6bb2..73f75474f 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -4338,10 +4338,17 @@ class SettingsRepository( fun getScreenshotsCleanedCount(): Int = getInt(KEY_SCREENSHOTS_CLEANED_COUNT, 0) fun getScreenshotsCleanedBytes(): Long = getLong(KEY_SCREENSHOTS_CLEANED_BYTES, 0L) - fun recordScreenshotCleaned(bytes: Long) { - putInt(KEY_SCREENSHOTS_CLEANED_COUNT, getScreenshotsCleanedCount() + 1) - putLong(KEY_SCREENSHOTS_CLEANED_BYTES, getScreenshotsCleanedBytes() + bytes) - } + fun recordScreenshotCleaned(bytes: Long) = + synchronized(SettingsRepository::class.java) { + putInt(KEY_SCREENSHOTS_CLEANED_COUNT, getScreenshotsCleanedCount() + 1) + putLong(KEY_SCREENSHOTS_CLEANED_BYTES, getScreenshotsCleanedBytes() + bytes) + } + + fun recordScreenshotRestored(bytes: Long) = + synchronized(SettingsRepository::class.java) { + putInt(KEY_SCREENSHOTS_CLEANED_COUNT, (getScreenshotsCleanedCount() - 1).coerceAtLeast(0)) + putLong(KEY_SCREENSHOTS_CLEANED_BYTES, (getScreenshotsCleanedBytes() - bytes).coerceAtLeast(0L)) + } fun getFaceUnlockMaxBrightness(): Int = getInt(KEY_FACE_UNLOCK_MAX_BRIGHTNESS, 100) fun setFaceUnlockMaxBrightness(value: Int) = putInt(KEY_FACE_UNLOCK_MAX_BRIGHTNESS, value) diff --git a/app/src/main/java/com/sameerasw/essentials/receivers/ScreenshotCleanupActionReceiver.kt b/app/src/main/java/com/sameerasw/essentials/receivers/ScreenshotCleanupActionReceiver.kt index 33f7fb3d2..249d7976e 100644 --- a/app/src/main/java/com/sameerasw/essentials/receivers/ScreenshotCleanupActionReceiver.kt +++ b/app/src/main/java/com/sameerasw/essentials/receivers/ScreenshotCleanupActionReceiver.kt @@ -14,6 +14,7 @@ import android.content.Context import android.content.Intent import android.widget.Toast import com.sameerasw.essentials.R +import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.utils.ScreenshotCleaner import com.sameerasw.essentials.utils.ScreenshotCleanupNotifier import kotlinx.coroutines.CoroutineScope @@ -28,25 +29,41 @@ class ScreenshotCleanupActionReceiver : BroadcastReceiver() { ) { val mediaId = intent.getLongExtra(EXTRA_MEDIA_ID, -1L) if (mediaId < 0) return - ScreenshotCleaner.cancel(context, mediaId) - ScreenshotCleanupNotifier.dismiss(context, mediaId) - - when (intent.action) { - ACTION_KEEP -> Toast.makeText(context, R.string.screenshot_kept, Toast.LENGTH_SHORT).show() + val days = intent.getIntExtra(EXTRA_DAYS, 7) + val pending = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + when (intent.action) { + ACTION_KEEP -> { + ScreenshotCleaner.cancel(context, mediaId) + ScreenshotCleanupNotifier.showResult(context, mediaId, R.string.screenshot_kept, days, NOT_TRASHED) + } - ACTION_TRASH_NOW -> { - val pending = goAsync() - CoroutineScope(Dispatchers.IO).launch { - try { - if (ScreenshotCleaner.trash(context, mediaId, keepStarred = false)) { + ACTION_TRASH_NOW -> { + val size = ScreenshotCleaner.trashSized(context, mediaId, keepStarred = false) + if (size == null) { withContext(Dispatchers.Main) { - Toast.makeText(context, R.string.screenshot_moved_to_trash, Toast.LENGTH_SHORT).show() + Toast.makeText(context, R.string.screenshot_trash_failed, Toast.LENGTH_SHORT).show() } + } else { + ScreenshotCleaner.cancel(context, mediaId) + ScreenshotCleanupNotifier.showResult(context, mediaId, R.string.screenshot_moved_to_trash, days, size) + } + } + + ACTION_UNDO -> { + val trashedBytes = intent.getLongExtra(EXTRA_TRASHED_BYTES, NOT_TRASHED) + val restored = trashedBytes == NOT_TRASHED || ScreenshotCleaner.restore(context, mediaId, trashedBytes) + if (restored && SettingsRepository(context).isScreenshotAutoCleanEnabled()) { + ScreenshotCleaner.schedule(context, mediaId, days * MINUTES_PER_DAY, ScreenshotCleaner.TAG_AUTO_CLEAN) + ScreenshotCleanupNotifier.show(context, mediaId, days) + } else { + ScreenshotCleanupNotifier.dismiss(context, mediaId) } - } finally { - pending.finish() } } + } finally { + pending.finish() } } } @@ -54,6 +71,11 @@ class ScreenshotCleanupActionReceiver : BroadcastReceiver() { companion object { const val ACTION_KEEP = "com.sameerasw.essentials.action.KEEP_SCREENSHOT" const val ACTION_TRASH_NOW = "com.sameerasw.essentials.action.TRASH_SCREENSHOT" + const val ACTION_UNDO = "com.sameerasw.essentials.action.UNDO_SCREENSHOT_ACTION" const val EXTRA_MEDIA_ID = "extra_media_id" + const val EXTRA_DAYS = "extra_days" + const val EXTRA_TRASHED_BYTES = "extra_trashed_bytes" + private const val NOT_TRASHED = -1L + private const val MINUTES_PER_DAY = 24 * 60L } } diff --git a/app/src/main/java/com/sameerasw/essentials/services/ScreenshotTrashWorker.kt b/app/src/main/java/com/sameerasw/essentials/services/ScreenshotTrashWorker.kt index ddfef3d95..bb7a0f6bf 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/ScreenshotTrashWorker.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/ScreenshotTrashWorker.kt @@ -22,12 +22,13 @@ class ScreenshotTrashWorker( override suspend fun doWork(): Result { val mediaId = inputData.getLong(KEY_MEDIA_ID, -1L) if (mediaId < 0) return Result.failure() - ScreenshotCleaner.trash(applicationContext, mediaId) + ScreenshotCleaner.trash(applicationContext, mediaId, inputData.getBoolean(KEY_KEEP_STARRED, true)) ScreenshotCleanupNotifier.dismiss(applicationContext, mediaId) return Result.success() } companion object { const val KEY_MEDIA_ID = "media_id" + const val KEY_KEEP_STARRED = "keep_starred" } } diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotAutoCleanHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotAutoCleanHandler.kt index e6ac125c6..73b889a1a 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotAutoCleanHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotAutoCleanHandler.kt @@ -30,18 +30,25 @@ class ScreenshotAutoCleanHandler( private val settings = SettingsRepository(context) private val mutex = Mutex() + private val handler = Handler(Looper.getMainLooper()) + + private val scan = Runnable { scope.launch(Dispatchers.IO) { scheduleNewScreenshots() } } + private val observer = - object : ContentObserver(Handler(Looper.getMainLooper())) { + object : ContentObserver(handler) { override fun onChange(selfChange: Boolean) { - scope.launch(Dispatchers.IO) { scheduleNewScreenshots() } + handler.removeCallbacks(scan) + handler.postDelayed(scan, SCAN_DEBOUNCE_MS) } } fun register() { + handler.post(scan) context.contentResolver.registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, observer) } fun unregister() { + handler.removeCallbacks(scan) context.contentResolver.unregisterContentObserver(observer) } @@ -60,6 +67,7 @@ class ScreenshotAutoCleanHandler( companion object { private const val MINUTES_PER_DAY = 24 * 60L - private const val NEW_SCREENSHOT_WINDOW_SECONDS = 60L + private const val NEW_SCREENSHOT_WINDOW_SECONDS = 6 * 60 * 60L + private const val SCAN_DEBOUNCE_MS = 1_000L } } diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotShareCleanupHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotShareCleanupHandler.kt index 4616a1a0c..9107cbe3f 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotShareCleanupHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotShareCleanupHandler.kt @@ -74,7 +74,7 @@ class ScreenshotShareCleanupHandler( return@withContext R.string.screenshot_share_cleanup_scheduled } ScreenshotCleaner.cancel(service, mediaId) - if (ScreenshotCleaner.trash(service, mediaId)) R.string.screenshot_share_cleanup_trashed else null + if (ScreenshotCleaner.trash(service, mediaId, keepStarred = false)) R.string.screenshot_share_cleanup_trashed else null } outcome?.let { Toast.makeText(service, it, Toast.LENGTH_SHORT).show() } } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleaner.kt b/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleaner.kt index 202a7f0c3..a765759c9 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleaner.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleaner.kt @@ -13,6 +13,7 @@ import android.content.ContentUris import android.content.ContentValues import android.content.Context import android.os.Build +import android.os.Bundle import android.os.Environment import android.provider.MediaStore import androidx.work.ExistingWorkPolicy @@ -60,25 +61,59 @@ object ScreenshotCleaner { buildList { while (cursor.moveToNext()) add(cursor.getLong(0)) } } ?: emptyList() + fun latestScreenshotId(context: Context): Long = + context.contentResolver + .query( + collection, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.RELATIVE_PATH} = ?", + arrayOf(SCREENSHOTS_PATH), + "${MediaStore.MediaColumns._ID} DESC", + )?.use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else 0L } ?: 0L + fun trash( context: Context, mediaId: Long, keepStarred: Boolean = true, - ): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false + ): Boolean = trashSized(context, mediaId, keepStarred) != null + + fun trashSized( + context: Context, + mediaId: Long, + keepStarred: Boolean = true, + ): Long? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null val uri = ContentUris.withAppendedId(collection, mediaId) val size = context.contentResolver .query(uri, arrayOf(MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.IS_FAVORITE), null, null, null) ?.use { cursor -> if (cursor.moveToFirst() && !(keepStarred && cursor.getInt(1) == 1)) cursor.getLong(0) else null } - ?: return false + ?: return null val trashed = runCatching { val values = ContentValues().apply { put(MediaStore.MediaColumns.IS_TRASHED, 1) } context.contentResolver.update(uri, values, null) > 0 }.getOrDefault(false) - if (trashed) SettingsRepository(context).recordScreenshotCleaned(size) - return trashed + if (!trashed) return null + SettingsRepository(context).recordScreenshotCleaned(size) + return size + } + + fun restore( + context: Context, + mediaId: Long, + bytes: Long, + ): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false + val uri = ContentUris.withAppendedId(collection, mediaId) + val extras = Bundle().apply { putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE) } + val restored = + runCatching { + val values = ContentValues().apply { put(MediaStore.MediaColumns.IS_TRASHED, 0) } + context.contentResolver.update(uri, values, extras) > 0 + }.getOrDefault(false) + if (restored && bytes > 0) SettingsRepository(context).recordScreenshotRestored(bytes) + return restored } fun schedule( @@ -87,20 +122,23 @@ object ScreenshotCleaner { delayMinutes: Long, tag: String, ) { + val keepStarred = tag != TAG_SHARED val request = OneTimeWorkRequestBuilder() .setInitialDelay(delayMinutes, TimeUnit.MINUTES) - .setInputData(workDataOf(ScreenshotTrashWorker.KEY_MEDIA_ID to mediaId)) + .setInputData(workDataOf(ScreenshotTrashWorker.KEY_MEDIA_ID to mediaId, ScreenshotTrashWorker.KEY_KEEP_STARRED to keepStarred)) .addTag(tag) .build() - WorkManager.getInstance(context).enqueueUniqueWork(workName(mediaId), ExistingWorkPolicy.REPLACE, request) + WorkManager.getInstance(context).enqueueUniqueWork(workName(mediaId, tag), ExistingWorkPolicy.REPLACE, request) } fun cancel( context: Context, mediaId: Long, ) { - WorkManager.getInstance(context).cancelUniqueWork(workName(mediaId)) + val workManager = WorkManager.getInstance(context) + workManager.cancelUniqueWork(workName(mediaId, TAG_SHARED)) + workManager.cancelUniqueWork(workName(mediaId, TAG_AUTO_CLEAN)) } fun cancelAll( @@ -110,5 +148,8 @@ object ScreenshotCleaner { WorkManager.getInstance(context).cancelAllWorkByTag(tag) } - private fun workName(mediaId: Long) = "screenshot_trash_$mediaId" + private fun workName( + mediaId: Long, + tag: String, + ) = "screenshot_trash_${tag}_$mediaId" } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleanupNotifier.kt b/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleanupNotifier.kt index 502c2a375..21b54a84f 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleanupNotifier.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleanupNotifier.kt @@ -29,9 +29,11 @@ import com.sameerasw.essentials.receivers.ScreenshotCleanupActionReceiver object ScreenshotCleanupNotifier { private const val CHANNEL_ID = "screenshot_cleanup" + private const val RESULT_CHANNEL_ID = "screenshot_cleanup_result" private const val NOTIFICATION_TAG = "screenshot_cleanup" private const val GROUP_KEY = "com.sameerasw.essentials.SCREENSHOT_CLEANUP" private const val TIMEOUT_MILLIS = 60 * 60 * 1000L + private const val RESULT_TIMEOUT_MILLIS = 5_000L fun show( context: Context, @@ -43,7 +45,7 @@ object ScreenshotCleanupNotifier { ) { return } - createChannel(context) + createChannels(context) val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, mediaId) val openIntent = @@ -64,11 +66,11 @@ object ScreenshotCleanupNotifier { ).addAction( R.drawable.rounded_bookmark_24, context.getString(R.string.screenshot_action_keep), - actionIntent(context, mediaId, ScreenshotCleanupActionReceiver.ACTION_KEEP, 1), + actionIntent(context, mediaId, ScreenshotCleanupActionReceiver.ACTION_KEEP, 1, days), ).addAction( R.drawable.rounded_delete_24, context.getString(R.string.screenshot_action_trash_now), - actionIntent(context, mediaId, ScreenshotCleanupActionReceiver.ACTION_TRASH_NOW, 2), + actionIntent(context, mediaId, ScreenshotCleanupActionReceiver.ACTION_TRASH_NOW, 2, days), ).setSilent(true) .setAutoCancel(true) .setGroup(GROUP_KEY) @@ -82,6 +84,36 @@ object ScreenshotCleanupNotifier { NotificationManagerCompat.from(context).notify(NOTIFICATION_TAG, mediaId.toInt(), builder.build()) } + fun showResult( + context: Context, + mediaId: Long, + message: Int, + days: Int, + trashedBytes: Long, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return + } + createChannels(context) + val undo = + actionIntent(context, mediaId, ScreenshotCleanupActionReceiver.ACTION_UNDO, 3, days) { putExtra(ScreenshotCleanupActionReceiver.EXTRA_TRASHED_BYTES, trashedBytes) } + val notification = + NotificationCompat + .Builder(context, RESULT_CHANNEL_ID) + .setSmallIcon(R.drawable.rounded_screenshot_region_24) + .setContentTitle(context.getString(message)) + .addAction(R.drawable.ic_undo, context.getString(R.string.screenshot_action_undo), undo) + .setSilent(true) + .setOnlyAlertOnce(true) + .setAutoCancel(true) + .setTimeoutAfter(RESULT_TIMEOUT_MILLIS) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + NotificationManagerCompat.from(context).notify(NOTIFICATION_TAG, mediaId.toInt(), notification) + } + fun dismiss( context: Context, mediaId: Long, @@ -89,15 +121,23 @@ object ScreenshotCleanupNotifier { NotificationManagerCompat.from(context).cancel(NOTIFICATION_TAG, mediaId.toInt()) } - private fun createChannel(context: Context) { + private fun createChannels(context: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - val channel = + val manager = context.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( NotificationChannel( CHANNEL_ID, context.getString(R.string.screenshot_cleanup_channel_name), NotificationManager.IMPORTANCE_LOW, - ).apply { setShowBadge(false) } - context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + ).apply { setShowBadge(false) }, + ) + manager.createNotificationChannel( + NotificationChannel( + RESULT_CHANNEL_ID, + context.getString(R.string.screenshot_cleanup_result_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { setShowBadge(false) }, + ) } private fun actionIntent( @@ -105,11 +145,15 @@ object ScreenshotCleanupNotifier { mediaId: Long, action: String, slot: Int, + days: Int, + extras: Intent.() -> Unit = {}, ): PendingIntent { val intent = Intent(context, ScreenshotCleanupActionReceiver::class.java).apply { this.action = action putExtra(ScreenshotCleanupActionReceiver.EXTRA_MEDIA_ID, mediaId) + putExtra(ScreenshotCleanupActionReceiver.EXTRA_DAYS, days) + extras() } return PendingIntent.getBroadcast( context, @@ -122,7 +166,7 @@ object ScreenshotCleanupNotifier { private fun requestCode( mediaId: Long, slot: Int, - ) = (mediaId * 3 + slot).toInt() + ) = (mediaId * 4 + slot).toInt() private fun loadThumbnail( context: Context, diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 46be93d1b..f4d353b74 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -9690,9 +9690,20 @@ class MainViewModel : ViewModel() { } fun setScreenshotAutoCleanEnabled(enabled: Boolean) { - settingsRepository.setScreenshotAutoCleanEnabled(enabled) isScreenshotAutoCleanEnabled.value = enabled - if (!enabled) appContext?.let { ScreenshotCleaner.cancelAll(it, ScreenshotCleaner.TAG_AUTO_CLEAN) } + val context = appContext + if (context == null) { + settingsRepository.setScreenshotAutoCleanEnabled(enabled) + return + } + viewModelScope.launch(Dispatchers.IO) { + if (enabled) { + settingsRepository.setScreenshotAutoCleanLastId(ScreenshotCleaner.latestScreenshotId(context)) + } else { + ScreenshotCleaner.cancelAll(context, ScreenshotCleaner.TAG_AUTO_CLEAN) + } + settingsRepository.setScreenshotAutoCleanEnabled(enabled) + } } fun setScreenshotAutoCleanDays(days: Int) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7edd6b200..7bc5f12cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3373,7 +3373,6 @@ Bump brightness in low light Delete shared screenshots Trash screenshots after sharing - After you share a screenshot from the screenshot preview, it is moved to the trash a minute after you leave the app you shared it to. Trashed screenshots can be restored from Files or Photos for 30 days. Shared screenshot moved to trash Shared screenshot will be moved to trash later Screenshots @@ -3396,6 +3395,9 @@ Trash now Screenshot kept Screenshot moved to trash + Couldn\'t move the screenshot to trash + Undo + Screenshot cleanup results Screenshot cleanup %d day From 20c9f02bfcbe6cbed55a4db92feecc9bbf2b57c7 Mon Sep 17 00:00:00 2001 From: Joseph Kuriackal Date: Fri, 16 Oct 2026 19:25:03 +0530 Subject: [PATCH 08/12] feat: widget stack remembers its position across reboots Each stack's current widget was only kept in memory, so a reboot, app update or killed process sent every stack back to its first widget. Positions are now saved per stack in widget_stack_prefs after an arrow or edge step, when the screen turns off and when the app is moved to the background, and restored (clamped to the hosted widgets) on start. Co-Authored-By: Claude Opus 5.5 --- .../data/repository/WidgetStackRepository.kt | 29 ++++++++ .../essentials/services/widgets/StackHost.kt | 23 +++++-- .../services/widgets/StackPositions.kt | 26 +++++++ .../essentials/services/StackPositionsTest.kt | 67 +++++++++++++++++++ 4 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/services/widgets/StackPositions.kt create mode 100644 app/src/test/java/com/sameerasw/essentials/services/StackPositionsTest.kt diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/WidgetStackRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/WidgetStackRepository.kt index e6456016d..1f43f349c 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/WidgetStackRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/WidgetStackRepository.kt @@ -11,6 +11,7 @@ package com.sameerasw.essentials.data.repository import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.sameerasw.essentials.domain.model.WidgetStackConfig @@ -43,6 +44,32 @@ class WidgetStackRepository( fun get(stackWidgetId: Int): WidgetStackConfig? = getAll().firstOrNull { it.stackWidgetId == stackWidgetId } + fun getPositions(): Map { + val result = mutableMapOf() + for ((key, value) in prefs.all) { + if (key.startsWith(KEY_POSITION_PREFIX) && value is Int) { + val id = key.removePrefix(KEY_POSITION_PREFIX).toIntOrNull() ?: continue + result[id] = value + } + } + return result + } + + fun savePositions(positions: Map) { + val toSave = + positions.filter { (id, pos) -> + val key = KEY_POSITION_PREFIX + id + val stored = if (prefs.contains(key)) prefs.getInt(key, 0) else null + stored != pos + } + if (toSave.isEmpty()) return + prefs.edit { + for ((id, pos) in toSave) { + putInt(KEY_POSITION_PREFIX + id, pos) + } + } + } + fun save(config: WidgetStackConfig) = synchronized(LOCK) { write(getAll().filterNot { it.stackWidgetId == config.stackWidgetId } + config) @@ -51,6 +78,7 @@ class WidgetStackRepository( fun delete(stackWidgetId: Int) = synchronized(LOCK) { write(getAll().filterNot { it.stackWidgetId == stackWidgetId }) + prefs.edit { remove(KEY_POSITION_PREFIX + stackWidgetId) } } private fun write(stacks: List) { @@ -63,6 +91,7 @@ class WidgetStackRepository( companion object { private const val PREFS_NAME = "widget_stack_prefs" private const val KEY_STACKS = "stacks" + private const val KEY_POSITION_PREFIX = "position_" private val LOCK = Any() private var cachedJson: String? = null private var cachedStacks: List = emptyList() diff --git a/app/src/main/java/com/sameerasw/essentials/services/widgets/StackHost.kt b/app/src/main/java/com/sameerasw/essentials/services/widgets/StackHost.kt index 620e53aec..1c82ca0a3 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/widgets/StackHost.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/widgets/StackHost.kt @@ -14,7 +14,7 @@ import android.appwidget.AppWidgetHostView import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProviderInfo import android.content.BroadcastReceiver -import android.content.ComponentCallbacks +import android.content.ComponentCallbacks2 import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -102,20 +102,31 @@ object StackHost { intent: Intent, ) { when (intent.action) { - Intent.ACTION_SCREEN_OFF -> cancelAllTimers() - Intent.ACTION_SCREEN_ON -> repository.getAll().forEach { scheduleTimer(it.stackWidgetId) } + Intent.ACTION_SCREEN_OFF -> { + cancelAllTimers() + repository.savePositions(positions) + } + + Intent.ACTION_SCREEN_ON -> { + repository.getAll().forEach { scheduleTimer(it.stackWidgetId) } + } } } } private val configurationCallbacks = - object : ComponentCallbacks { + object : ComponentCallbacks2 { override fun onConfigurationChanged(newConfig: Configuration) { repository.getAll().forEach { StackWidgetProvider.forwardSize(appContext, it) } } @Deprecated("Deprecated in Java") override fun onLowMemory() {} + + override fun onTrimMemory(level: Int) { + // Cached apps get frozen and miss SCREEN_OFF, so save before that happens. + if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) repository.savePositions(positions) + } } fun startIfNeeded(context: Context) { @@ -140,6 +151,9 @@ object StackHost { if (host == null) start(app) reveal(stackWidgetId) advance(stackWidgetId, delta) + positions[stackWidgetId]?.let { pos -> + repository.savePositions(mapOf(stackWidgetId to pos)) + } } } @@ -177,6 +191,7 @@ object StackHost { PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build(), ) repository = WidgetStackRepository(context) + positions.putAll(StackPositions.restore(repository.getPositions(), repository.getAll())) host = CapturingWidgetHost(context).also { it.startListening() } removeOrphanedWidgets() attachOverlay() diff --git a/app/src/main/java/com/sameerasw/essentials/services/widgets/StackPositions.kt b/app/src/main/java/com/sameerasw/essentials/services/widgets/StackPositions.kt new file mode 100644 index 000000000..190913f30 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/widgets/StackPositions.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Background Services & Receivers + * File: StackPositions.kt + * Description: Restores and validates widget stack positions. + */ + +package com.sameerasw.essentials.services.widgets + +import com.sameerasw.essentials.domain.model.WidgetStackConfig + +object StackPositions { + fun restore( + saved: Map, + stacks: List, + ): Map = + buildMap { + for (stack in stacks) { + if (stack.hostedWidgetIds.isEmpty()) continue + val pos = saved[stack.stackWidgetId] ?: continue + put(stack.stackWidgetId, pos.coerceIn(0, stack.hostedWidgetIds.lastIndex)) + } + } +} diff --git a/app/src/test/java/com/sameerasw/essentials/services/StackPositionsTest.kt b/app/src/test/java/com/sameerasw/essentials/services/StackPositionsTest.kt new file mode 100644 index 000000000..a736441b5 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/services/StackPositionsTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Unit Tests - Services + * File: StackPositionsTest.kt + * Description: Unit tests for StackPositions restore logic. + */ + +package com.sameerasw.essentials.services + +import com.sameerasw.essentials.domain.model.WidgetStackConfig +import com.sameerasw.essentials.services.widgets.StackPositions +import org.junit.Assert.assertEquals +import org.junit.Test + +class StackPositionsTest { + @Test + fun keepsSavedPositionInRange() { + val stacks = listOf(WidgetStackConfig(stackWidgetId = 1, hostedWidgetIds = listOf(10, 11, 12))) + val saved = mapOf(1 to 1) + assertEquals(mapOf(1 to 1), StackPositions.restore(saved, stacks)) + } + + @Test + fun clampsSavedPositionAboveLastIndexToLastIndex() { + val stacks = listOf(WidgetStackConfig(stackWidgetId = 1, hostedWidgetIds = listOf(10, 11, 12))) + val saved = mapOf(1 to 5) + assertEquals(mapOf(1 to 2), StackPositions.restore(saved, stacks)) + } + + @Test + fun clampsNegativeSavedPositionToZero() { + val stacks = listOf(WidgetStackConfig(stackWidgetId = 1, hostedWidgetIds = listOf(10, 11, 12))) + val saved = mapOf(1 to -3) + assertEquals(mapOf(1 to 0), StackPositions.restore(saved, stacks)) + } + + @Test + fun dropsPositionsForStackIdsNotInStacks() { + val stacks = listOf(WidgetStackConfig(stackWidgetId = 1, hostedWidgetIds = listOf(10, 11))) + val saved = mapOf(1 to 1, 2 to 0, 99 to 3) + assertEquals(mapOf(1 to 1), StackPositions.restore(saved, stacks)) + } + + @Test + fun leavesOutStacksWithNoHostedWidgets() { + val stacks = + listOf( + WidgetStackConfig(stackWidgetId = 1, hostedWidgetIds = emptyList()), + WidgetStackConfig(stackWidgetId = 2, hostedWidgetIds = listOf(20)), + ) + val saved = mapOf(1 to 0, 2 to 0) + assertEquals(mapOf(2 to 0), StackPositions.restore(saved, stacks)) + } + + @Test + fun leavesOutStacksWithNoSavedPosition() { + val stacks = + listOf( + WidgetStackConfig(stackWidgetId = 1, hostedWidgetIds = listOf(10, 11)), + WidgetStackConfig(stackWidgetId = 2, hostedWidgetIds = listOf(20, 21)), + ) + val saved = mapOf(1 to 1) + assertEquals(mapOf(1 to 1), StackPositions.restore(saved, stacks)) + } +} From 35b92f2ef2d9b8e05f348b56e0ec23301fd8ce91 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 09:01:25 +0530 Subject: [PATCH 09/12] Widget stack: drop comment and braces noise --- .../com/sameerasw/essentials/services/widgets/StackHost.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/services/widgets/StackHost.kt b/app/src/main/java/com/sameerasw/essentials/services/widgets/StackHost.kt index 1c82ca0a3..3be4751a7 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/widgets/StackHost.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/widgets/StackHost.kt @@ -107,9 +107,7 @@ object StackHost { repository.savePositions(positions) } - Intent.ACTION_SCREEN_ON -> { - repository.getAll().forEach { scheduleTimer(it.stackWidgetId) } - } + Intent.ACTION_SCREEN_ON -> repository.getAll().forEach { scheduleTimer(it.stackWidgetId) } } } } @@ -124,7 +122,6 @@ object StackHost { override fun onLowMemory() {} override fun onTrimMemory(level: Int) { - // Cached apps get frozen and miss SCREEN_OFF, so save before that happens. if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) repository.savePositions(positions) } } From e5f6420e87e97353f7163970f246c4843d9f9878 Mon Sep 17 00:00:00 2001 From: Joseph Kuriackal Date: Fri, 16 Oct 2026 23:08:17 +0530 Subject: [PATCH 10/12] feat: add Send notification DIY action Posts a notification with a user-set title, message and label (shown as subtext next to the app name) on its own "Automation notifications" channel. Running the same action again replaces its notification. Co-Authored-By: Claude Opus 5.5 --- .../sameerasw/essentials/domain/diy/Action.kt | 12 ++ .../essentials/domain/diy/ActionRegistry.kt | 1 + .../executors/CombinedActionExecutor.kt | 2 + .../sheets/SendNotificationSettingsSheet.kt | 149 ++++++++++++++++++ .../features/hardware/ActionSequenceEditor.kt | 25 ++- .../utils/AutomationNotificationPoster.kt | 58 +++++++ app/src/main/res/values/strings.xml | 6 + .../essentials/domain/ActionListJsonTest.kt | 16 ++ 8 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/utils/AutomationNotificationPoster.kt diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt index 9468add4a..e2acdb4ac 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt @@ -156,6 +156,18 @@ sealed interface Action { override val isConfigurable: Boolean get() = true } + @Keep + data class SendNotification( + @SerializedName("title") val notificationTitle: String = "", + @SerializedName("body") val body: String = "", + @SerializedName("label") val label: String = "", + ) : Action { + override val title: Int get() = R.string.diy_action_send_notification + override val icon: Int get() = R.drawable.rounded_notifications_unread_24 + override val permissions: List get() = listOf("POST_NOTIFICATIONS") + override val isConfigurable: Boolean get() = true + } + @Keep data object TurnOffLowPower : Action { override val title: Int = R.string.diy_action_low_power_off diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt index 236ef2bfa..17253ab27 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt @@ -82,6 +82,7 @@ object ActionRegistry { add(Action.SetChargingMode()) add(Action.CustomSettings()) add(Action.Wait()) + add(Action.SendNotification()) add(Action.CircleToSearch) add(Action.TurnOffBackGesture) add(Action.TurnOnBackGesture) diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt index fb3a91aba..ab755f7b7 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt @@ -49,6 +49,7 @@ import com.sameerasw.essentials.services.NotificationListener import com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService import com.sameerasw.essentials.ui.activities.PixelSearchResultsActivity import com.sameerasw.essentials.utils.AutomationFailureNotifier +import com.sameerasw.essentials.utils.AutomationNotificationPoster import com.sameerasw.essentials.utils.DeviceLockUtils import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.utils.PermissionUtils @@ -171,6 +172,7 @@ object CombinedActionExecutor { is Action.TurnOnCaffeinate -> CaffeinateController.start(context) is Action.TurnOffCaffeinate -> CaffeinateController.cancelAll(context) is Action.Wait -> delay(action.seconds * 1000L) + is Action.SendNotification -> AutomationNotificationPoster.post(context, action) is Action.TurnOffLowPower -> setLowPowerMode(context, false) is Action.SetChargingMode -> com.sameerasw.essentials.utils.battery.ChargingModeUtil diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt new file mode 100644 index 000000000..ce60c143a --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Automation + * File: SendNotificationSettingsSheet.kt + * Description: UI bottom sheet for configuring the Send Notification automation action. + */ + +package com.sameerasw.essentials.ui.core.sheets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.utils.HapticUtil + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SendNotificationSettingsSheet( + initialAction: Action.SendNotification, + onDismiss: () -> Unit, + onSave: (Action.SendNotification) -> Unit, +) { + val view = LocalView.current + var title by remember { mutableStateOf(initialAction.notificationTitle) } + var body by remember { mutableStateOf(initialAction.body) } + var label by remember { mutableStateOf(initialAction.label) } + + EssentialsBottomSheet( + onDismissRequest = onDismiss, + dragHandle = null, + ) { + Column( + modifier = + Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.diy_action_send_notification), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + RoundedCardContainer(spacing = 8.dp) { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text(stringResource(R.string.diy_send_notification_title)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = body, + onValueChange = { body = it }, + label = { Text(stringResource(R.string.diy_send_notification_body)) }, + maxLines = 4, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = label, + onValueChange = { label = it }, + label = { Text(stringResource(R.string.diy_send_notification_label)) }, + supportingText = { Text(stringResource(R.string.diy_send_notification_label_desc)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onDismiss() + }, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_close_24), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.action_cancel)) + } + + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onSave( + Action.SendNotification( + notificationTitle = title.trim(), + body = body.trim(), + label = label.trim(), + ), + ) + }, + modifier = Modifier.weight(1f), + enabled = title.isNotBlank() || body.isNotBlank(), + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_check_24), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.action_save)) + } + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt index 5df69b626..95626eb4f 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt @@ -58,6 +58,7 @@ import com.sameerasw.essentials.ui.core.sheets.ScreenOffSettingsSheet import com.sameerasw.essentials.ui.core.sheets.OpenActivityPicker import com.sameerasw.essentials.ui.core.sheets.SingleAppSelectionSheet import com.sameerasw.essentials.ui.core.sheets.WaitSettingsSheet +import com.sameerasw.essentials.ui.core.sheets.SendNotificationSettingsSheet import com.sameerasw.essentials.ui.core.sheets.formatWait import com.sameerasw.essentials.ui.core.sheets.ChargingModeSettingsSheet import com.sameerasw.essentials.ui.core.sheets.NotificationLightingActionSheet @@ -110,6 +111,7 @@ fun ActionSequenceEditor( var showSetVolumeSettings by remember { mutableStateOf(false) } var showHilightSettings by remember { mutableStateOf(false) } var showWaitSettings by remember { mutableStateOf(false) } + var showSendNotificationSettings by remember { mutableStateOf(false) } var configAction by remember { mutableStateOf(null) } var configIndex by remember { mutableStateOf(null) } var showAddActionSheet by remember { mutableStateOf(false) } @@ -146,6 +148,7 @@ fun ActionSequenceEditor( "WRITE_SECURE_SETTINGS" -> !viewModel.isWriteSecureSettingsEnabled.value "DRAW_OVERLAYS" -> !viewModel.isOverlayPermissionGranted.value "ACCESSIBILITY" -> !viewModel.isAccessibilityEnabled.value + "POST_NOTIFICATIONS" -> !viewModel.isPostNotificationsEnabled.value else -> false } } @@ -222,6 +225,7 @@ fun ActionSequenceEditor( is Action.Hilight -> showHilightSettings = true is Action.CustomSettings -> showCustomSettingsSettings = true is Action.Wait -> showWaitSettings = true + is Action.SendNotification -> showSendNotificationSettings = true else -> {} } } @@ -623,6 +627,18 @@ fun ActionSequenceEditor( ) } + if (showSendNotificationSettings && configAction is Action.SendNotification) { + SendNotificationSettingsSheet( + initialAction = configAction as Action.SendNotification, + onDismiss = { showSendNotificationSettings = false }, + onSave = { newAction -> + showSendNotificationSettings = false + onActionSelected(newAction) + configAction = null + }, + ) + } + if (showPermissionSheet) { val permissionItems = com.sameerasw.essentials.utils.PermissionUIHelper.getPermissionItems( @@ -655,7 +671,14 @@ private fun RemapSequenceItem( onRemove: () -> Unit, ) { val view = LocalView.current - val title = stringResource(action.title).let { if (action is Action.Wait) "$it · ${formatWait(action.seconds)}" else it } + val title = + stringResource(action.title).let { baseTitle -> + when { + action is Action.Wait -> "$baseTitle · ${formatWait(action.seconds)}" + action is Action.SendNotification && action.notificationTitle.isNotBlank() -> "$baseTitle · ${action.notificationTitle}" + else -> baseTitle + } + } Row( modifier = Modifier diff --git a/app/src/main/java/com/sameerasw/essentials/utils/AutomationNotificationPoster.kt b/app/src/main/java/com/sameerasw/essentials/utils/AutomationNotificationPoster.kt new file mode 100644 index 000000000..60786686a --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/AutomationNotificationPoster.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Utilities - General + * File: AutomationNotificationPoster.kt + * Description: Posts user-configured notifications for automation actions. + */ + +package com.sameerasw.essentials.utils + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.diy.Action + +object AutomationNotificationPoster { + private const val CHANNEL_ID = "automation_notifications" + + fun post( + context: Context, + action: Action.SendNotification, + ) { + if (!PermissionUtils.isPostNotificationsEnabled(context)) return + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.automation_notifications_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ), + ) + } + + val builder = + NotificationCompat + .Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_name) + .setAutoCancel(true) + + if (action.notificationTitle.isNotBlank()) { + builder.setContentTitle(action.notificationTitle) + } + if (action.body.isNotBlank()) { + builder.setContentText(action.body) + builder.setStyle(NotificationCompat.BigTextStyle().bigText(action.body)) + } + if (action.label.isNotBlank()) { + builder.setSubText(action.label) + } + + manager.notify(action.hashCode(), builder.build()) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7bc5f12cd..d0ac8dda1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1457,6 +1457,11 @@ Wait Duration Pause before the next action runs + Send notification + Title + Message + Label + Shown next to the app name Turn Off Low Power Mode Set Charging Mode Trigger notification lighting @@ -1607,6 +1612,7 @@ Automations + Automation notifications Automation failed %1$s: %2$s Missing permission: %1$s diff --git a/app/src/test/java/com/sameerasw/essentials/domain/ActionListJsonTest.kt b/app/src/test/java/com/sameerasw/essentials/domain/ActionListJsonTest.kt index c2862cf6e..84e6a5722 100644 --- a/app/src/test/java/com/sameerasw/essentials/domain/ActionListJsonTest.kt +++ b/app/src/test/java/com/sameerasw/essentials/domain/ActionListJsonTest.kt @@ -22,4 +22,20 @@ class ActionListJsonTest { fun invalidJsonGivesEmptyList() { assertEquals(emptyList(), ActionGsonAdapter.listFromJson("not json")) } + + @Test + fun sendNotificationRoundTrip() { + val actions = listOf(Action.SendNotification(notificationTitle = "Title", body = "Body", label = "Label")) + assertEquals(actions, ActionGsonAdapter.listFromJson(ActionGsonAdapter.listToJson(actions))) + } + + @Test + fun sendNotificationMissingFieldsLoadEmpty() { + val singleAction = ActionGsonAdapter.fromJson("""{"type":"SendNotification","title":"Hi"}""") + assertEquals(Action.SendNotification(notificationTitle = "Hi", body = "", label = ""), singleAction) + + val json = """[{"type":"SendNotification","title":"Hi"}]""" + val expected = listOf(Action.SendNotification(notificationTitle = "Hi", body = "", label = "")) + assertEquals(expected, ActionGsonAdapter.listFromJson(json)) + } } From 36f2e60649c306eda23105dc077a49434ee419d2 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 09:06:25 +0530 Subject: [PATCH 11/12] Send notification: open app on tap, create channel once, ellipsize long titles, drop helper text --- .../sheets/SendNotificationSettingsSheet.kt | 1 - .../features/hardware/ActionSequenceEditor.kt | 3 ++ .../utils/AutomationNotificationPoster.kt | 35 ++++++++++++++----- app/src/main/res/values/strings.xml | 1 - 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt index ce60c143a..3f4e7b91e 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt @@ -90,7 +90,6 @@ fun SendNotificationSettingsSheet( value = label, onValueChange = { label = it }, label = { Text(stringResource(R.string.diy_send_notification_label)) }, - supportingText = { Text(stringResource(R.string.diy_send_notification_label_desc)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt index 95626eb4f..fd53d7edf 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.diy.Action @@ -725,6 +726,8 @@ private fun RemapSequenceItem( Text( text = title, style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurface, ) diff --git a/app/src/main/java/com/sameerasw/essentials/utils/AutomationNotificationPoster.kt b/app/src/main/java/com/sameerasw/essentials/utils/AutomationNotificationPoster.kt index 60786686a..1bcc7c883 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/AutomationNotificationPoster.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/AutomationNotificationPoster.kt @@ -11,6 +11,7 @@ package com.sameerasw.essentials.utils import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context import android.os.Build import androidx.core.app.NotificationCompat @@ -20,27 +21,28 @@ import com.sameerasw.essentials.domain.diy.Action object AutomationNotificationPoster { private const val CHANNEL_ID = "automation_notifications" + @Volatile + private var channelCreated = false + fun post( context: Context, action: Action.SendNotification, ) { if (!PermissionUtils.isPostNotificationsEnabled(context)) return val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - manager.createNotificationChannel( - NotificationChannel( - CHANNEL_ID, - context.getString(R.string.automation_notifications_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, - ), - ) - } + ensureChannel(context, manager) + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) val builder = NotificationCompat .Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_name) .setAutoCancel(true) + .setContentIntent( + launchIntent?.let { + PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + }, + ) if (action.notificationTitle.isNotBlank()) { builder.setContentTitle(action.notificationTitle) @@ -55,4 +57,19 @@ object AutomationNotificationPoster { manager.notify(action.hashCode(), builder.build()) } + + private fun ensureChannel( + context: Context, + manager: NotificationManager, + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || channelCreated) return + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.automation_notifications_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ), + ) + channelCreated = true + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0ac8dda1..9039340bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1461,7 +1461,6 @@ Title Message Label - Shown next to the app name Turn Off Low Power Mode Set Charging Mode Trigger notification lighting From a4053d35322d683eaa1a5588121e4f86093d1f55 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 09:07:55 +0530 Subject: [PATCH 12/12] Send notification sheet: card-style fields like other sheets --- .../sheets/SendNotificationSettingsSheet.kt | 69 ++++++++++++++----- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt index 3f4e7b91e..98a1d15a7 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt @@ -9,19 +9,23 @@ package com.sameerasw.essentials.ui.core.sheets +import androidx.compose.foundation.background 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.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -33,6 +37,7 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.diy.Action @@ -51,10 +56,7 @@ fun SendNotificationSettingsSheet( var body by remember { mutableStateOf(initialAction.body) } var label by remember { mutableStateOf(initialAction.label) } - EssentialsBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - ) { + EssentialsBottomSheet(onDismissRequest = onDismiss) { Column( modifier = Modifier @@ -64,34 +66,30 @@ fun SendNotificationSettingsSheet( ) { Text( text = stringResource(R.string.diy_action_send_notification), - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, ) - RoundedCardContainer(spacing = 8.dp) { - OutlinedTextField( + RoundedCardContainer(spacing = 2.dp, cornerRadius = 24.dp) { + NotificationField( value = title, onValueChange = { title = it }, - label = { Text(stringResource(R.string.diy_send_notification_title)) }, + label = stringResource(R.string.diy_send_notification_title), singleLine = true, - modifier = Modifier.fillMaxWidth(), ) - - OutlinedTextField( + NotificationField( value = body, onValueChange = { body = it }, - label = { Text(stringResource(R.string.diy_send_notification_body)) }, - maxLines = 4, - modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.diy_send_notification_body), + minLines = 3, + maxLines = 6, ) - - OutlinedTextField( + NotificationField( value = label, onValueChange = { label = it }, - label = { Text(stringResource(R.string.diy_send_notification_label)) }, + label = stringResource(R.string.diy_send_notification_label), singleLine = true, - modifier = Modifier.fillMaxWidth(), ) } @@ -146,3 +144,38 @@ fun SendNotificationSettingsSheet( } } } + +@Composable +private fun NotificationField( + value: String, + onValueChange: (String) -> Unit, + label: String, + singleLine: Boolean = false, + minLines: Int = 1, + maxLines: Int = Int.MAX_VALUE, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.extraSmall) + .padding(12.dp), + ) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + singleLine = singleLine, + minLines = minLines, + maxLines = if (singleLine) 1 else maxLines, + shape = MaterialTheme.shapes.medium, + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + modifier = Modifier.fillMaxWidth(), + ) + } +}