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))
+ }
+}