diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ed3b93dcf1..c2a85aacdd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1738,6 +1738,10 @@ android:name=".receivers.UrlShortenerActionReceiver" android:exported="false" /> + + { + 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/domain/diy/Action.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt index 9468add4aa..e2acdb4acc 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 236ef2bfa8..17253ab277 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/domain/model/NavigationNotificationData.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/NavigationNotificationData.kt new file mode 100644 index 0000000000..fa9dd97da7 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/NavigationNotificationData.kt @@ -0,0 +1,67 @@ +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?, + 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 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, + ) + + fun isDistance(s: String?) = s != null && s.length <= 24 && distanceRegex.containsMatchIn(s) + + 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?($units)(?!\p{L})""", RegexOption.IGNORE_CASE) + + 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())) + } + + fun exitActionIndex(titles: List): Int = + titles.indexOfFirst { it != null && exitRegex.containsMatchIn(it) } +} 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 3349c1d907..220349b979 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 @@ -16,6 +16,7 @@ import com.sameerasw.essentials.ui.features.display.ISLAND_PLACEMENT_FEATURE_ID import com.sameerasw.essentials.ui.features.display.ISLAND_VISUALS_FEATURE_ID import android.content.Context import android.content.Intent +import android.os.Build import com.sameerasw.essentials.EssentialsApp import com.sameerasw.essentials.FeatureSettingsActivity import com.sameerasw.essentials.R @@ -825,6 +826,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.island_show_hotspot_title, R.string.island_hotspot_desc, @@ -2950,30 +2956,50 @@ object FeatureRegistry { ) = viewModel.setFaceUnlockBrightnessEnabled(enabled) }, object : Feature( - id = "Screenshot share cleanup", - title = R.string.feat_screenshot_share_cleanup_title, + id = "Screenshots", + title = R.string.feat_screenshots_title, iconRes = R.drawable.rounded_screenshot_region_24, category = R.string.cat_tools, - description = R.string.feat_screenshot_share_cleanup_desc, - aboutDescription = R.string.about_desc_screenshot_share_cleanup, + description = R.string.feat_screenshots_desc, + aboutDescription = R.string.about_desc_screenshots, permissionKeys = listOf("ACCESSIBILITY", "STORAGE"), - hasMoreSettings = false, + showToggle = false, + searchableSettings = + listOf( + SearchSetting( + R.string.feat_screenshot_share_cleanup_title, + R.string.feat_screenshots_title, + "screenshot_share_cleanup_enabled", + ), + SearchSetting( + R.string.screenshot_share_cleanup_delay_title, + R.string.feat_screenshots_title, + "screenshot_share_cleanup_delay", + ), + SearchSetting( + R.string.screenshot_auto_clean_title, + R.string.feat_screenshots_title, + "screenshot_auto_clean_enabled", + ), + SearchSetting( + R.string.screenshot_auto_clean_days_title, + R.string.feat_screenshots_title, + "screenshot_auto_clean_days", + ), + ), parentFeatureId = "Security", ) { - override fun isEnabled(viewModel: MainViewModel) = viewModel.isScreenshotShareCleanupEnabled.value - - override fun isToggleEnabled( - viewModel: MainViewModel, - context: Context, - ) = viewModel.isAccessibilityEnabled.value && viewModel.isStoragePermissionGranted.value + override fun isEnabled(viewModel: MainViewModel) = + viewModel.isScreenshotShareCleanupEnabled.value || viewModel.isScreenshotAutoCleanEnabled.value override fun onToggle( viewModel: MainViewModel, context: Context, enabled: Boolean, - ) = viewModel.setScreenshotShareCleanupEnabled(enabled) + ) {} - override fun isDeviceSupported(context: Context) = DeviceUtils.isGoogleDevice() + override fun isDeviceSupported(context: Context) = + DeviceUtils.isGoogleDevice() && Build.VERSION.SDK_INT >= 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 c6cbab35d1..10d21f9f7d 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/island/model/IslandModels.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt index ffa3e205ae..4441c1e29c 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,8 +21,9 @@ object IslandPriority { const val CALENDAR_OVERRIDE = 6 const val NOTIFICATION = 10 - 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 56d2f4795e..a823c44e5f 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 @@ -7,6 +7,7 @@ object IslandPriorityEntries { Entry("battery", setOf("battery_alerts_plugin", "battery"), IslandPriority.BATTERY_ALERT), Entry("notifications", setOf("notifications"), IslandPriority.NOTIFICATION), 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/island/plugins/navigation/NavigationExpanded.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationExpanded.kt new file mode 100644 index 0000000000..1d9eefe7cd --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationExpanded.kt @@ -0,0 +1,92 @@ +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 +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.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.ui.theme.EssentialsTheme + +@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()) }, + end = { + data.distance?.let { + Text(it, style = IslandTextStyles.title.copy(color = accent), maxLines = 1) + } + }, + ) + if (!data.instruction.isNullOrBlank()) { + Spacer(Modifier.height(8.dp)) + Text( + 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), + ) + } + 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 0000000000..8ecb378b67 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/navigation/NavigationPlugin.kt @@ -0,0 +1,244 @@ +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 + + private val trigger = NavigationTrigger() + + private val endRoute = Runnable { trigger.reset() } + + private val endExpand = Runnable { + val c = ctx ?: return@Runnable + 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) + ctx?.mainHandler?.removeCallbacks(endRoute) + data = null + endRoute.run() + } + + 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) { + 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 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 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) + } + + 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" + + private const val EXPANDED_MS = 6_000L + private const val COLLAPSE_ANIM_MS = 600L + private const val ROUTE_GRACE_MS = 60_000L + } +} + +@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 + ) + } +} + +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/island/service/IslandCoordinator.kt b/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt index 491d8fa831..cc4b611cc6 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 @@ -47,6 +47,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.hotspot.HotspotPlugin import com.sameerasw.essentials.island.plugins.network.NetworkPlugin import com.sameerasw.essentials.island.plugins.signal.SignalPlugin @@ -151,6 +152,7 @@ class IslandCoordinator( RemoteProvidersPlugin(), CaffeinatePlugin(), TravelPlugin(), + NavigationPlugin(), NetworkPlugin(), SignalPlugin(), HotspotPlugin(), 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 0000000000..249d7976ef --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/receivers/ScreenshotCleanupActionReceiver.kt @@ -0,0 +1,81 @@ +/* + * 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.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.withContext + +class ScreenshotCleanupActionReceiver : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + val mediaId = intent.getLongExtra(EXTRA_MEDIA_ID, -1L) + if (mediaId < 0) return + 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 size = ScreenshotCleaner.trashSized(context, mediaId, keepStarred = false) + if (size == null) { + withContext(Dispatchers.Main) { + 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() + } + } + } + + 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/NotificationListener.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt index 7087037efa..3764d5f6c2 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,8 @@ 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)) { + scheduleProgressRefresh() } else if (HilightProgressTracker.isTracking(sbn.key)) { // Its progress bar was replaced, e.g. by "Download complete" scheduleProgressRefresh() @@ -1869,6 +1897,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() @@ -1908,11 +1944,64 @@ 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 } } + 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 + .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 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() }) + 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 = postTime, + ) + } + fun isHeadsUpNotification( sbn: StatusBarNotification, rankingMap: RankingMap? = null, 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 0000000000..bb7a0f6bfe --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/ScreenshotTrashWorker.kt @@ -0,0 +1,34 @@ +/* + * 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, 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/automation/executors/CombinedActionExecutor.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt index fb3a91abad..ab755f7b74 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/services/handlers/ScreenshotAutoCleanHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotAutoCleanHandler.kt new file mode 100644 index 0000000000..73b889a1a6 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/ScreenshotAutoCleanHandler.kt @@ -0,0 +1,73 @@ +/* + * 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 handler = Handler(Looper.getMainLooper()) + + private val scan = Runnable { scope.launch(Dispatchers.IO) { scheduleNewScreenshots() } } + + private val observer = + object : ContentObserver(handler) { + override fun onChange(selfChange: Boolean) { + 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) + } + + 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 = 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 e48aaccb9b..9107cbe3f8 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, keepStarred = false)) 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 e7b75d4860..2f0ef76e36 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/services/widgets/StackHost.kt b/app/src/main/java/com/sameerasw/essentials/services/widgets/StackHost.kt index 620e53aec7..3be4751a74 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,28 @@ object StackHost { intent: Intent, ) { when (intent.action) { - Intent.ACTION_SCREEN_OFF -> cancelAllTimers() + 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) { + if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) repository.savePositions(positions) + } } fun startIfNeeded(context: Context) { @@ -140,6 +148,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 +188,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 0000000000..190913f304 --- /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/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt index 9df16ed07c..062292c610 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/automation/sheets/SendNotificationSettingsSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt new file mode 100644 index 0000000000..98a1d15a72 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/automation/sheets/SendNotificationSettingsSheet.kt @@ -0,0 +1,181 @@ +/* + * 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.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 +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.text.input.KeyboardCapitalization +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) { + Column( + modifier = + Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.diy_action_send_notification), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + RoundedCardContainer(spacing = 2.dp, cornerRadius = 24.dp) { + NotificationField( + value = title, + onValueChange = { title = it }, + label = stringResource(R.string.diy_send_notification_title), + singleLine = true, + ) + NotificationField( + value = body, + onValueChange = { body = it }, + label = stringResource(R.string.diy_send_notification_body), + minLines = 3, + maxLines = 6, + ) + NotificationField( + value = label, + onValueChange = { label = it }, + label = stringResource(R.string.diy_send_notification_label), + singleLine = true, + ) + } + + 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)) + } + } + } + } +} + +@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(), + ) + } +} 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 4517789f83..9f643d3141 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 @@ -80,6 +80,7 @@ import com.sameerasw.essentials.ui.features.display.sheets.IslandAppOptionsBotto 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 import com.sameerasw.essentials.ui.features.display.sheets.IslandCaffeinateOptionsBottomSheet @@ -123,6 +124,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) } @@ -431,6 +433,18 @@ 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), + isChecked = viewModel.isIslandShowNavigation.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setIslandShowNavigation(checked) + }, + onSettingsClick = { showNavigationOptionsSheet = true }, + modifier = Modifier.highlight(highlightSetting == "island_show_navigation"), + ) "hotspot" -> IconToggleItem( dragHandle = handle, iconRes = R.drawable.rounded_wifi_tethering_24, @@ -792,6 +806,13 @@ fun IslandSettingsUI( ) } + if (showNavigationOptionsSheet) { + IslandNavigationOptionsBottomSheet( + viewModel = viewModel, + 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 0000000000..12d3edf47a --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNavigationOptionsBottomSheet.kt @@ -0,0 +1,120 @@ +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.ui.Modifier +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 +import com.sameerasw.essentials.viewmodels.MainViewModel + +private val EXPAND_DISTANCE_OPTIONS = listOf(0, 50, 100, 200, 500) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IslandNavigationOptionsBottomSheet( + viewModel: MainViewModel, + onDismissRequest: () -> Unit, +) { + val view = LocalView.current + + @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 = viewModel.islandNavigationOnNew.value, + onItemSelected = { viewModel.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), + selectedValue = distanceLabel(viewModel.islandNavigationExpandDistance.intValue), + iconRes = R.drawable.rounded_magnify_fullscreen_24, + modifier = Modifier.fillMaxWidth(), + ) { + EXPAND_DISTANCE_OPTIONS.forEach { meters -> + SegmentedDropdownMenuItem( + text = { Text(distanceLabel(meters)) }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.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 = viewModel.isIslandNavigationPeekAfterExpand.value, + onCheckedChange = { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setIslandNavigationPeekAfterExpand(it) + }, + ) + } + } + } +} 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 5df69b6267..fd53d7edf4 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 @@ -58,6 +59,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 +112,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 +149,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 +226,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 +628,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 +672,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 @@ -702,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/ui/features/security/ScreenshotsSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/security/ScreenshotsSettingsUI.kt new file mode 100644 index 0000000000..3dca043f9a --- /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/AutomationNotificationPoster.kt b/app/src/main/java/com/sameerasw/essentials/utils/AutomationNotificationPoster.kt new file mode 100644 index 0000000000..1bcc7c883f --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/AutomationNotificationPoster.kt @@ -0,0 +1,75 @@ +/* + * 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.app.PendingIntent +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" + + @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 + 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) + } + 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()) + } + + 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/java/com/sameerasw/essentials/utils/ScreenshotCleaner.kt b/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleaner.kt new file mode 100644 index 0000000000..a765759c9f --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleaner.kt @@ -0,0 +1,155 @@ +/* + * 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.Bundle +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 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 = 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 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) 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( + context: Context, + mediaId: Long, + delayMinutes: Long, + tag: String, + ) { + val keepStarred = tag != TAG_SHARED + val request = + OneTimeWorkRequestBuilder() + .setInitialDelay(delayMinutes, TimeUnit.MINUTES) + .setInputData(workDataOf(ScreenshotTrashWorker.KEY_MEDIA_ID to mediaId, ScreenshotTrashWorker.KEY_KEEP_STARRED to keepStarred)) + .addTag(tag) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(workName(mediaId, tag), ExistingWorkPolicy.REPLACE, request) + } + + fun cancel( + context: Context, + mediaId: Long, + ) { + val workManager = WorkManager.getInstance(context) + workManager.cancelUniqueWork(workName(mediaId, TAG_SHARED)) + workManager.cancelUniqueWork(workName(mediaId, TAG_AUTO_CLEAN)) + } + + fun cancelAll( + context: Context, + tag: String, + ) { + WorkManager.getInstance(context).cancelAllWorkByTag(tag) + } + + 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 new file mode 100644 index 0000000000..21b54a84f5 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/ScreenshotCleanupNotifier.kt @@ -0,0 +1,179 @@ +/* + * 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 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, + mediaId: Long, + days: Int, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return + } + createChannels(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, days), + ).addAction( + R.drawable.rounded_delete_24, + context.getString(R.string.screenshot_action_trash_now), + actionIntent(context, mediaId, ScreenshotCleanupActionReceiver.ACTION_TRASH_NOW, 2, days), + ).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 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, + ) { + NotificationManagerCompat.from(context).cancel(NOTIFICATION_TAG, mediaId.toInt()) + } + + private fun createChannels(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + 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) }, + ) + manager.createNotificationChannel( + NotificationChannel( + RESULT_CHANNEL_ID, + context.getString(R.string.screenshot_cleanup_result_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { setShowBadge(false) }, + ) + } + + private fun actionIntent( + context: Context, + 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, + requestCode(mediaId, slot), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun requestCode( + mediaId: Long, + slot: Int, + ) = (mediaId * 4 + 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 a1ec2d838f..f4d353b747 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 @@ -248,6 +249,10 @@ class MainViewModel : ViewModel() { val islandAlarmWindowHours = mutableIntStateOf(12) val isIslandShowTravel = mutableStateOf(true) val isIslandShowCaffeinate = mutableStateOf(true) + val isIslandShowNavigation = mutableStateOf(true) + val islandNavigationOnNew = mutableStateOf(SettingsRepository.ISLAND_NAVIGATION_ON_NEW_PEEK) + val islandNavigationExpandDistance = mutableIntStateOf(200) + val isIslandNavigationPeekAfterExpand = mutableStateOf(false) val isIslandShowHotspot = mutableStateOf(false) val isIslandShowDevices = mutableStateOf(true) val isIslandBriefEnabled = mutableStateOf(false) @@ -399,6 +404,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) @@ -960,6 +970,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() @@ -1388,6 +1410,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() @@ -2439,6 +2476,10 @@ class MainViewModel : ViewModel() { isDuoKeepInFullscreen.value = settingsRepository.isDuoKeepInFullscreenEnabled() 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() @@ -2828,6 +2869,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() @@ -5633,6 +5679,26 @@ class MainViewModel : ViewModel() { settingsRepository.setIslandShowDevicesEnabled(enabled) } + fun setIslandShowNavigation(enabled: Boolean) { + isIslandShowNavigation.value = enabled + 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) @@ -9615,6 +9681,34 @@ 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) { + isScreenshotAutoCleanEnabled.value = enabled + 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) { + 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 4c5138d153..9039340bc8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1457,6 +1457,10 @@ Wait Duration Pause before the next action runs + Send notification + Title + Message + Label Turn Off Low Power Mode Set Charging Mode Trigger notification lighting @@ -1607,6 +1611,7 @@ Automations + Automation notifications Automation failed %1$s: %2$s Missing permission: %1$s @@ -3294,6 +3299,19 @@ 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 + Off + %d m + Line peek after expanding + When off, the island goes straight back to compact Hotspot Shows while your hotspot is on, with connected devices and data shared @@ -3360,8 +3378,40 @@ 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 + 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 + Couldn\'t move the screenshot to trash + Undo + Screenshot cleanup results + 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 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 c2862cf6e1..84e6a57224 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)) + } } 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 0000000000..1efbc58c75 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/domain/NavigationParserTest.kt @@ -0,0 +1,81 @@ +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 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) + assertNull(p.distance) + } + + @Test + fun exitAction() { + assertEquals(1, NavigationParser.exitActionIndex(listOf("Mute", "Exit navigation"))) + assertEquals(0, NavigationParser.exitActionIndex(listOf("Salir"))) + assertEquals(-1, NavigationParser.exitActionIndex(listOf("Something"))) + assertEquals(-1, NavigationParser.exitActionIndex(listOf("Mute"))) + 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) + 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 0000000000..026ab6e578 --- /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)) + } +} 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 0000000000..a736441b54 --- /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)) + } +}