From 911819151aa7dca4800f3f30d4625efc3950f301 Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Wed, 14 Oct 2026 13:16:30 -0600 Subject: [PATCH 01/24] feat(island): add OTP notification handler filtering --- .../data/repository/SettingsRepository.kt | 12 ++ .../domain/model/ActiveNotificationAlert.kt | 1 + .../essentials/island/model/IslandModels.kt | 2 + .../notifications/NotificationsPlugin.kt | 156 +++++++++++++++++- .../plugins/notifications/OtpClipboard.kt | 30 ++++ .../plugins/notifications/OtpExpanded.kt | 136 +++++++++++++++ .../plugins/notifications/OtpExtractor.kt | 88 ++++++++++ .../island/state/IslandController.kt | 5 +- .../services/NotificationListener.kt | 16 ++ .../features/display/IslandSubSettingsUI.kt | 61 +++++++ .../sheets/IslandLauncherOnlyToggle.kt | 2 + app/src/main/res/values/strings.xml | 15 ++ .../essentials/island/OtpExtractorTest.kt | 76 +++++++++ 13 files changed, 590 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpClipboard.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExpanded.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExtractor.kt create mode 100644 app/src/test/java/com/sameerasw/essentials/island/OtpExtractorTest.kt diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 7d9e1965a..9eef38f7a 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -637,6 +637,11 @@ class SettingsRepository( const val KEY_ISLAND_NOTIF_FILTER_APPS_LIST = "island_notif_filter_apps_list" const val KEY_ISLAND_SHOW_NOTIFICATIONS = "island_show_notifications" const val KEY_ISLAND_NOTIF_TAP_TO_OPEN = "island_notif_tap_to_open" + const val KEY_ISLAND_OTP_ENABLED = "island_otp_enabled" + const val KEY_ISLAND_OTP_TAP_TO_COPY = "island_otp_tap_to_copy" + const val KEY_ISLAND_OTP_DISMISS_AFTER_COPY = "island_otp_dismiss_after_copy" + const val KEY_ISLAND_OTP_BYPASS_FILTER = "island_otp_bypass_filter" + const val KEY_ISLAND_OTP_TIMEOUT_MS = "island_otp_timeout_ms" const val KEY_ISLAND_BORDER_OUTLINE_ENABLED = "island_border_outline_enabled" const val KEY_ISLAND_BORDER_OUTLINE_COLOR = "island_border_outline_color" const val KEY_ISLAND_BORDER_OUTLINE_DYNAMIC = "island_border_outline_dynamic" @@ -4187,6 +4192,13 @@ class SettingsRepository( fun isIslandNotifTapToOpenEnabled(): Boolean = getBoolean(KEY_ISLAND_NOTIF_TAP_TO_OPEN, false) fun setIslandNotifTapToOpenEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_NOTIF_TAP_TO_OPEN, enabled) + fun isIslandOtpEnabled(): Boolean = getBoolean(KEY_ISLAND_OTP_ENABLED, true) + fun isIslandOtpTapToCopyEnabled(): Boolean = getBoolean(KEY_ISLAND_OTP_TAP_TO_COPY, true) + fun isIslandOtpDismissAfterCopyEnabled(): Boolean = getBoolean(KEY_ISLAND_OTP_DISMISS_AFTER_COPY, false) + fun isIslandOtpBypassFilterEnabled(): Boolean = getBoolean(KEY_ISLAND_OTP_BYPASS_FILTER, true) + fun getIslandOtpTimeoutMs(): Long = getLong(KEY_ISLAND_OTP_TIMEOUT_MS, 60_000L) + fun setIslandOtpTimeoutMs(ms: Long) = putLong(KEY_ISLAND_OTP_TIMEOUT_MS, ms) + fun isIslandNotifCompactHeadsUpEnabled(): Boolean = getBoolean(KEY_ISLAND_NOTIF_COMPACT_HEADS_UP, true) fun setIslandNotifCompactHeadsUpEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_NOTIF_COMPACT_HEADS_UP, enabled) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/ActiveNotificationAlert.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/ActiveNotificationAlert.kt index 78250276c..c7497f26e 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/ActiveNotificationAlert.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/ActiveNotificationAlert.kt @@ -28,5 +28,6 @@ data class ActiveNotificationAlert( val chatIcon: Bitmap? = null, val isSilent: Boolean = false, val blockedByDnd: Boolean = false, + val otpCode: String? = null, ) 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 e88755a70..3ff28b2db 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 @@ -76,6 +76,8 @@ class InteractionOverrides( val onTap: (() -> Boolean)? = null, val onLongPress: (() -> Unit)? = null, val onExpandedTap: (() -> Boolean)? = null, + // Return true to consume a tap on the line peek instead of expanding + val onLineTap: (() -> Boolean)? = null, ) { companion object { val Default = InteractionOverrides() diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt index 3b055b03c..8f93fda7b 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt @@ -28,6 +28,7 @@ import com.sameerasw.essentials.island.plugins.launchPackage import com.sameerasw.essentials.island.plugins.sendPendingIntent import com.sameerasw.essentials.island.plugins.soften import com.sameerasw.essentials.island.ui.components.IslandBitmap +import com.sameerasw.essentials.island.ui.components.IslandIcon import com.sameerasw.essentials.services.NotificationListener import com.sameerasw.essentials.utils.HapticUtil @@ -49,6 +50,11 @@ class NotificationsPlugin : BaseIslandPlugin() { SettingsRepository.KEY_ISLAND_SHOW_NOTIFICATIONS, SettingsRepository.KEY_ISLAND_NOTIF_CONCEAL_LOCKED, SettingsRepository.KEY_ISLAND_NOTIF_CONCEAL_CHAT_PICTURES, + SettingsRepository.KEY_ISLAND_OTP_ENABLED, + SettingsRepository.KEY_ISLAND_OTP_TAP_TO_COPY, + SettingsRepository.KEY_ISLAND_OTP_DISMISS_AFTER_COPY, + SettingsRepository.KEY_ISLAND_OTP_BYPASS_FILTER, + SettingsRepository.KEY_ISLAND_OTP_TIMEOUT_MS, ) private val alerts = ArrayDeque() @@ -119,6 +125,10 @@ class NotificationsPlugin : BaseIslandPlugin() { private val timeoutRunnable = Runnable { onTimeout() } private val catchUpRunnable = Runnable { clearAll() } + // Key of the alert whose code was just copied; shows the confirmation until copiedRunnable runs + private var copiedKey: String? = null + private val copiedRunnable = Runnable { finishCopy() } + private val listener = object : NotificationListener.NotificationAlertListener { override fun onNotificationAlertPosted(alert: ActiveNotificationAlert) { ctx?.mainHandler?.post { onPosted(alert) } @@ -164,10 +174,23 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun passesFilters(alert: ActiveNotificationAlert): Boolean { if (alert.isSilent && settings.isIslandNotifSkipSilentEnabled()) return false if (alert.blockedByDnd && settings.isIslandNotifFollowDndEnabled()) return false + if (isOtp(alert) && settings.isIslandOtpBypassFilterEnabled()) return true if (!settings.isIslandNotifFilterAppsEnabled()) return true return settings.loadIslandNotifFilterApps().any { it.isEnabled && it.packageName == alert.packageName } } + private fun otpCodeOf(alert: ActiveNotificationAlert) = alert.otpCode?.takeIf { settings.isIslandOtpEnabled() } + + private fun isOtp(alert: ActiveNotificationAlert) = otpCodeOf(alert) != null + + private fun timeoutMs(): Long { + val base = settings.getIslandTimeoutMs() + return if (currentAlert()?.let(::otpCodeOf) != null) settings.getIslandOtpTimeoutMs() else base + } + + // Same length and grouping as the code: 482 913 -> ••• ••• + private fun maskOtp(code: String) = formatOtp(code).map { if (it == ' ') ' ' else '•' }.joinToString("") + private fun concealed(): Boolean = settings.getBoolean(SettingsRepository.KEY_ISLAND_NOTIF_CONCEAL_LOCKED, false) && (context.getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager)?.isKeyguardLocked == true @@ -217,9 +240,10 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun popUp() { val c = ctx ?: return - val lineAvailable = settings.isIslandLineStageEnabled() && settings.isIslandNotifCompactHeadsUpEnabled() + val otpNeedsExpand = currentAlert()?.let(::isOtp) == true && !settings.isIslandOtpTapToCopyEnabled() + val lineAvailable = settings.isIslandLineStageEnabled() && settings.isIslandNotifCompactHeadsUpEnabled() && !otpNeedsExpand c.request( - if (lineAvailable) PluginRequest.Peek(ITEM_KEY, settings.getIslandTimeoutMs(), sticky = true) + if (lineAvailable) PluginRequest.Peek(ITEM_KEY, timeoutMs(), sticky = true) else PluginRequest.Expand(ITEM_KEY).also { autoExpanded = true }, ) } @@ -270,16 +294,18 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun scheduleTimeout() { val handler = ctx?.mainHandler ?: return handler.removeCallbacks(timeoutRunnable) - handler.postDelayed(timeoutRunnable, settings.getIslandTimeoutMs()) + handler.postDelayed(timeoutRunnable, timeoutMs()) } private fun cancelTimers() { ctx?.mainHandler?.removeCallbacks(timeoutRunnable) ctx?.mainHandler?.removeCallbacks(catchUpRunnable) + ctx?.mainHandler?.removeCallbacks(copiedRunnable) } private fun clearAll() { catchingUp = false + copiedKey = null cancelTimers() alerts.clear() currentIndex = 0 @@ -301,6 +327,10 @@ class NotificationsPlugin : BaseIslandPlugin() { } private fun render() { + if (copiedKey != null) { + publish(copiedItem()) + return + } val alert = currentAlert() if (alert == null) { publish(null) @@ -342,6 +372,8 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun itemFor(alert: ActiveNotificationAlert): IslandItem { if (concealed()) return concealedItemFor(alert) + val code = otpCodeOf(alert) + if (code != null) return otpItemFor(alert, code) val (sender, message) = senderAndMessage(context, alert) val showGlow = settings.isIslandShowGlowEnabled() val tapToOpen = settings.isIslandNotifTapToOpenEnabled() @@ -419,12 +451,21 @@ class NotificationsPlugin : BaseIslandPlugin() { add(CompactCell("notif.app", soloOnly = true) { IslandBitmap(appIcon, 22.dp, circle = true) }) } }, - line = LineContent( - icon = { IslandBitmap(icon, 24.dp, fallbackRes = R.drawable.rounded_notifications_unread_24) }, - start = appNameFor(context, alert), - end = "", - condensed = condensedLine(), - ), + line = if (isOtp(alert)) { + LineContent( + icon = { IslandBitmap(alert.appIcon ?: alert.icon, 24.dp, fallbackRes = R.drawable.rounded_lock_24) }, + start = "${appNameFor(context, alert)} · ${context.getString(R.string.island_otp_label)}", + end = alert.otpCode?.let(::maskOtp).orEmpty(), + endSlot = { IslandIcon(R.drawable.rounded_lock_24, size = 18.dp) }, + ) + } else { + LineContent( + icon = { IslandBitmap(icon, 24.dp, fallbackRes = R.drawable.rounded_notifications_unread_24) }, + start = appNameFor(context, alert), + end = "", + condensed = condensedLine(), + ) + }, accent = alert.appColor?.let { Color(soften(it)) }, dismissible = true, onDismiss = { @@ -438,6 +479,102 @@ class NotificationsPlugin : BaseIslandPlugin() { ) } + private fun otpItemFor(alert: ActiveNotificationAlert, code: String): IslandItem { + val (sender, message) = senderAndMessage(context, alert) + val showGlow = settings.isIslandShowGlowEnabled() + val icon = alert.appIcon ?: alert.icon + val copy = { copyCode(alert, code) } + val dismiss = { + NotificationListener.dismissNotification(alert.key) + popCurrent(reExpand = false) + } + return IslandItem( + key = ITEM_KEY, + priority = IslandPriority.NOTIFICATION, + placement = CompactPlacement.Dynamic, + compact = listOf( + CompactCell("notif.icon") { + IslandBitmap(icon, 22.dp, fallbackRes = R.drawable.rounded_notifications_unread_24) + }, + ), + line = LineContent( + icon = { IslandBitmap(icon, 24.dp, fallbackRes = R.drawable.rounded_notifications_unread_24) }, + start = sender, + end = formatOtp(code), + endSlot = { IslandIcon(R.drawable.rounded_content_copy_24, size = 18.dp) }, + ), + expanded = ExpandedContent { scope -> + OtpExpanded( + alert = alert, + code = code, + sender = sender, + message = message, + showGlow = showGlow, + onCopy = copy, + onDismiss = dismiss, + scope = scope, + ) + }, + accent = alert.appColor?.let { Color(soften(it)) }, + dismissible = true, + onDismiss = dismiss, + onOpen = { + if (!sendPendingIntent(context, alert.contentIntent)) launchPackage(context, alert.packageName) + popCurrent(reExpand = false) + }, + interactions = if (settings.isIslandOtpTapToCopyEnabled()) { + InteractionOverrides(onTap = { copy(); true }, onLineTap = { copy(); true }) + } else { + InteractionOverrides.Default + }, + sourcePackage = alert.packageName, + contentId = alert.key, + ) + } + + private fun copiedItem() = IslandItem( + key = ITEM_KEY, + priority = IslandPriority.NOTIFICATION, + placement = CompactPlacement.Dynamic, + compact = listOf(CompactCell("notif.copied") { IslandIcon(R.drawable.rounded_check_24, size = 20.dp) }), + line = LineContent( + icon = { IslandIcon(R.drawable.rounded_check_24, size = 22.dp) }, + start = context.getString(R.string.island_otp_copied), + end = "", + ), + contentId = "otp.copied", + ) + + private fun copyCode(alert: ActiveNotificationAlert, code: String) { + val c = ctx ?: return + if (!OtpClipboard.copy(context, code)) return + HapticUtil.performStrongTickHaptic(context) + cancelTimers() + copiedKey = alert.key + render() + c.request(PluginRequest.Collapse(ITEM_KEY)) + c.request(PluginRequest.Peek(ITEM_KEY, COPIED_MS)) + c.mainHandler.postDelayed(copiedRunnable, COPIED_MS) + } + + private fun finishCopy() { + val key = copiedKey ?: return + copiedKey = null + if (settings.isIslandOtpDismissAfterCopyEnabled()) NotificationListener.dismissNotification(key) + val index = alerts.indexOfFirst { it.key == key } + if (index >= 0) { + alerts.removeAt(index) + if (index < currentIndex) currentIndex-- + currentIndex = currentIndex.coerceIn(0, (alerts.size - 1).coerceAtLeast(0)) + } + if (alerts.isEmpty()) { + clearAll() + return + } + scheduleTimeout() + render() + } + private fun stackIconFor(alert: ActiveNotificationAlert, current: Boolean): StackIcon { val icon = if (concealed() && hidesChatPictures()) alert.appIcon ?: alert.icon else alert.chatIcon ?: alert.appIcon ?: alert.icon return StackIcon(alert.key, current = current, onSelect = { select(alert.key) }) { size -> @@ -491,6 +628,7 @@ class NotificationsPlugin : BaseIslandPlugin() { companion object { const val ITEM_KEY = "notifications" private const val MAX_QUEUE = 20 + private const val COPIED_MS = 1_500L private val GENERIC_TITLES = setOf( "you", "whatsapp", "messages", "telegram", "gmail", "instagram", "slack", "discord", "essentials", diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpClipboard.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpClipboard.kt new file mode 100644 index 000000000..bf8a83d3a --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpClipboard.kt @@ -0,0 +1,30 @@ +package com.sameerasw.essentials.island.plugins.notifications + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.os.PersistableBundle +import com.sameerasw.essentials.R + +object OtpClipboard { + fun copy(context: Context, code: String): Boolean { + val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return false + val clip = ClipData.newPlainText(context.getString(R.string.island_otp_clip_label), code) + // Keeps keyboards and the system clipboard preview from showing the code + clip.description.extras = PersistableBundle().apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } else { + putBoolean("android.content.extra.IS_SENSITIVE", true) + } + } + return try { + clipboard.setPrimaryClip(clip) + true + } catch (_: Exception) { + false + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExpanded.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExpanded.kt new file mode 100644 index 000000000..e5825b6c5 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExpanded.kt @@ -0,0 +1,136 @@ +package com.sameerasw.essentials.island.plugins.notifications + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.model.ActiveNotificationAlert +import com.sameerasw.essentials.island.model.IslandExpandedScope +import com.sameerasw.essentials.island.ui.IslandTextStyles +import com.sameerasw.essentials.island.ui.components.CameraRow +import com.sameerasw.essentials.island.ui.components.ConnectedButtonRow +import com.sameerasw.essentials.island.ui.components.ConnectedItem +import com.sameerasw.essentials.island.ui.components.ConnectedTextLabel +import com.sameerasw.essentials.island.ui.components.IslandBitmap +import com.sameerasw.essentials.island.ui.components.MarqueeText +import com.sameerasw.essentials.island.ui.components.accentGlow +import com.sameerasw.essentials.island.ui.components.cameraClearance + +// Groups a code for reading: 1234 -> 1234, 482913 -> 482 913, 48291374 -> 4829 1374 +fun formatOtp(code: String): String = when { + code.length <= 5 -> code + code.length == 6 -> "${code.take(3)} ${code.drop(3)}" + code.length == 8 -> "${code.take(4)} ${code.drop(4)}" + else -> code +} + +@Composable +fun OtpExpanded( + alert: ActiveNotificationAlert, + code: String, + sender: String, + message: String, + showGlow: Boolean, + onCopy: () -> Unit, + onDismiss: () -> Unit, + scope: IslandExpandedScope, +) { + val spec = scope.spec + val sidePadding = spec.expandedPadding + spec.expandedCorner * 0.35f + val accent = alert.appColor?.let { Color(it) } ?: Color.White + var showMessage by remember(alert.key) { mutableStateOf(false) } + Box(propagateMinConstraints = true) { + Box(Modifier.matchParentSize().accentGlow(accent, showGlow, scope.cameraClearance)) + Column( + modifier = Modifier.fillMaxWidth().padding(spec.expandedOutset), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Spacer(Modifier.height(spec.expandedTopPadding)) + scope.CameraRow( + horizontalPadding = spec.cameraGap + spec.expandedCorner * 0.35f, + start = { + IslandBitmap(alert.appIcon ?: alert.icon, spec.cellSize, fallbackRes = R.drawable.rounded_notifications_unread_24) + MarqueeText(text = sender, style = IslandTextStyles.title, modifier = Modifier.weight(1f)) + }, + end = { + Text(stringResource(R.string.island_otp_label), style = IslandTextStyles.body, maxLines = 1) + }, + ) + Spacer(Modifier.height(10.dp)) + Text( + text = formatOtp(code), + style = IslandTextStyles.title.copy( + fontFamily = FontFamily.Monospace, + fontSize = 32.sp, + letterSpacing = 4.sp, + ), + textAlign = TextAlign.Center, + maxLines = 1, + modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding), + ) + if (message.isNotBlank()) { + Spacer(Modifier.height(6.dp)) + if (!showMessage) { + Text( + text = stringResource(R.string.island_otp_show_message), + style = IslandTextStyles.body, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .clickable(interactionSource = null, indication = null) { + showMessage = true + scope.keepAlive() + }, + ) + } + AnimatedVisibility(visible = showMessage) { + Text( + text = message, + style = IslandTextStyles.body.copy(fontSize = 14.sp, lineHeight = 19.sp), + maxLines = 5, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding), + ) + } + } + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(14.dp)) + ConnectedButtonRow( + height = 36.dp, + modifier = Modifier.padding(horizontal = sidePadding), + container = Color.White.copy(alpha = 0.2f), + items = listOf( + ConnectedItem(onDismiss) { ConnectedTextLabel(stringResource(R.string.island_otp_dismiss)) }, + ConnectedItem(onCopy, container = accent.copy(alpha = 0.55f)) { + ConnectedTextLabel(stringResource(R.string.island_otp_copy)) + }, + ), + ) + Spacer(Modifier.height(spec.expandedBottomPadding)) + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExtractor.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExtractor.kt new file mode 100644 index 000000000..18c43d373 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExtractor.kt @@ -0,0 +1,88 @@ +package com.sameerasw.essentials.island.plugins.notifications + +// Finds a one-time code in notification text. Returns null when unsure. +// A missed code beats a wrong one. +object OtpExtractor { + private val KEYWORD = Regex( + "\\b(otp|code|codes|passcode|pin|2fa|mfa|verification|verify|one[- ]time|" + + "c[oó]digo|clave|verificaci[oó]n|contrase[nñ]a|senha|verifica[cç][aã]o|" + + "bestätigungscode|sicherheitscode|vérification|codice|token|nip)\\b", + RegexOption.IGNORE_CASE, + ) + + private val CANDIDATES = listOf( + // G-482913 + Regex("\\b[A-Z]{1,3}-(\\d{4,8})\\b"), + // 482 913 / 482-913 + Regex("(?() + var best: Pair? = null + for (pattern in CANDIDATES) { + for (match in pattern.findAll(text)) { + val group = match.groups[1] ?: continue + if (seen.any { it.overlaps(group.range) }) continue + seen += group.range + if (!looksLikeCode(text, group.range, group.value)) continue + val distance = keywords.minOf { distance(it, group.range) } + if (distance > MAX_KEYWORD_DISTANCE) continue + if (best == null || distance < best.second) best = normalize(group.value) to distance + } + } + return best?.first + } + + private fun looksLikeCode(text: String, range: IntRange, value: String): Boolean { + val before = text.substring(maxOf(0, range.first - 24), range.first) + val after = text.substring(range.last + 1, minOf(text.length, range.last + 4)) + if (CURRENCY_BEFORE.containsMatchIn(before)) return false + if (NOT_A_CODE_BEFORE.containsMatchIn(before)) return false + if (after.matches(Regex("^[.,:/]\\d.*", RegexOption.DOT_MATCHES_ALL))) return false + if (after.startsWith("%")) return false + if (before.matches(Regex(".*\\d[.,:/]$", RegexOption.DOT_MATCHES_ALL))) return false + val digits = value.filter { it.isDigit() } + if (digits.length == value.length && digits.length == 4 && digits.toInt() in 1900..2099) { + if (!KEYWORD_RIGHT_BEFORE.containsMatchIn(before)) return false + } + return true + } + + private fun IntRange.overlaps(other: IntRange) = first <= other.last && other.first <= last + + private fun distance(a: IntRange, b: IntRange): Int = when { + a.overlaps(b) -> 0 + a.last < b.first -> b.first - a.last + else -> a.first - b.last + } + + private fun normalize(value: String) = value.filterNot { it == ' ' || it == '-' } +} diff --git a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt index 437ea248d..5a870fe7b 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt @@ -116,7 +116,10 @@ class IslandController( collapse() true } - IslandStage.Line -> current.focusedKey?.let { expand(it); true } ?: false + IslandStage.Line -> current.focusedKey?.let { + if (current.focused?.interactions?.onLineTap?.invoke() != true) expand(it) + true + } ?: false IslandStage.Compact -> { val target = itemKey?.let { current.items[it] }?.takeIf { it.expanded != null } ?: defaultTapTarget(current) diff --git a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt index 02528f2b1..efabdfb0c 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt @@ -9,6 +9,7 @@ package com.sameerasw.essentials.services +import com.sameerasw.essentials.island.plugins.notifications.OtpExtractor import android.graphics.drawable.Icon import android.content.pm.LauncherApps import com.sameerasw.essentials.domain.controller.HilightController @@ -280,6 +281,20 @@ class NotificationListener : NotificationListenerService() { return !ranking.matchesInterruptionFilter() || peekSuppressed } + // Every text field that can carry a code + private fun otpSourceText(sbn: StatusBarNotification): String { + val extras = sbn.notification.extras ?: return "" + return listOf( + Notification.EXTRA_TITLE, + Notification.EXTRA_TITLE_BIG, + Notification.EXTRA_TEXT, + Notification.EXTRA_BIG_TEXT, + ).mapNotNull { extras.getCharSequence(it)?.toString() } + .filter { it.isNotBlank() } + .distinct() + .joinToString("\n") + } + private fun populateActiveUnreadNotifications() { unreadNotifications.clear() try { @@ -1030,6 +1045,7 @@ class NotificationListener : NotificationListenerService() { val alert = extractNotificationAlert(sbn)?.copy( isSilent = isSilentNotification(sbn, rankingMap), blockedByDnd = isBlockedByDnd(sbn, rankingMap), + otpCode = OtpExtractor.extract(otpSourceText(sbn)), ) if (alert != null) { notifyAlertPosted(alert) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt index 575fe3f79..50d89222c 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt @@ -863,6 +863,12 @@ fun IslandNotificationSettingsUI( val context = LocalContext.current val view = LocalView.current var showFilterAppsSheet by remember { mutableStateOf(false) } + var otpEnabled by remember { + mutableStateOf(SettingsRepository(context).isIslandOtpEnabled()) + } + var otpTimeoutMs by remember { + mutableStateOf(SettingsRepository(context).getIslandOtpTimeoutMs()) + } var concealed by remember { mutableStateOf( SettingsRepository(context).getBoolean(SettingsRepository.KEY_ISLAND_NOTIF_CONCEAL_LOCKED, false) @@ -1071,6 +1077,57 @@ fun IslandNotificationSettingsUI( } } + IslandSettingsSection(R.string.island_otp_section) { + IslandPrefToggle( + settingKey = SettingsRepository.KEY_ISLAND_OTP_ENABLED, + iconRes = R.drawable.rounded_vpn_key_24, + title = stringResource(R.string.island_otp_enabled_title), + description = stringResource(R.string.island_otp_enabled_desc), + defaultValue = true, + onChanged = { otpEnabled = it }, + ) + AnimatedVisibility( + visible = otpEnabled, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + IslandPrefToggle( + settingKey = SettingsRepository.KEY_ISLAND_OTP_TAP_TO_COPY, + iconRes = R.drawable.rounded_content_copy_24, + title = stringResource(R.string.island_otp_tap_to_copy_title), + description = stringResource(R.string.island_otp_tap_to_copy_desc), + defaultValue = true, + ) + IslandPrefToggle( + settingKey = SettingsRepository.KEY_ISLAND_OTP_DISMISS_AFTER_COPY, + iconRes = R.drawable.rounded_check_circle_24, + title = stringResource(R.string.island_otp_dismiss_after_copy_title), + ) + ConfigSliderItem( + title = stringResource(R.string.island_otp_timeout_title), + value = OTP_TIMEOUT_STEPS_MS.indexOfClosest(otpTimeoutMs).toFloat(), + onValueChange = { + HapticUtil.performUIHaptic(view) + otpTimeoutMs = OTP_TIMEOUT_STEPS_MS[it.toInt().coerceIn(OTP_TIMEOUT_STEPS_MS.indices)] + SettingsRepository(context).setIslandOtpTimeoutMs(otpTimeoutMs) + }, + valueRange = 0f..OTP_TIMEOUT_STEPS_MS.lastIndex.toFloat(), + increment = 1f, + iconRes = R.drawable.rounded_timer_24, + valueFormatter = { formatCatchUp(OTP_TIMEOUT_STEPS_MS[it.toInt().coerceIn(OTP_TIMEOUT_STEPS_MS.indices)], "") }, + ) + IslandPrefToggle( + settingKey = SettingsRepository.KEY_ISLAND_OTP_BYPASS_FILTER, + iconRes = R.drawable.rounded_filter_alt_24, + title = stringResource(R.string.island_otp_bypass_filter_title), + description = stringResource(R.string.island_otp_bypass_filter_desc), + defaultValue = true, + ) + } + } + } + if (rememberIslandShowsWhileLocked()) { IslandSettingsSection(R.string.island_notif_section_lock_screen) { IslandPrefToggle( @@ -1126,6 +1183,10 @@ private fun IslandSettingsSection(titleRes: Int, content: @Composable ColumnScop private val CATCH_UP_STEPS_MS = longArrayOf(5_000, 10_000, 15_000, 20_000, 30_000, 45_000, 60_000, 120_000, 180_000, 300_000, 600_000, 900_000, 0) +private val OTP_TIMEOUT_STEPS_MS = longArrayOf(15_000, 30_000, 60_000, 120_000, 180_000, 300_000) + +private fun LongArray.indexOfClosest(ms: Long): Int = indices.minBy { kotlin.math.abs(this[it] - ms) } + private fun catchUpStepIndex(ms: Long): Int = if (ms <= 0L) CATCH_UP_STEPS_MS.lastIndex else CATCH_UP_STEPS_MS.indices.filter { CATCH_UP_STEPS_MS[it] > 0 }.minBy { kotlin.math.abs(CATCH_UP_STEPS_MS[it] - ms) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandLauncherOnlyToggle.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandLauncherOnlyToggle.kt index 1fac44d56..f20a7992e 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandLauncherOnlyToggle.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandLauncherOnlyToggle.kt @@ -43,6 +43,7 @@ fun IslandPrefToggle( iconRes: Int, title: String, modifier: Modifier = Modifier, + description: String? = null, defaultValue: Boolean = false, onChanged: (Boolean) -> Unit = {} ) { @@ -53,6 +54,7 @@ fun IslandPrefToggle( IconToggleItem( iconRes = iconRes, title = title, + description = description, isChecked = checked, onCheckedChange = { HapticUtil.performVirtualKeyHaptic(view) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7dab93c4..60773a072 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3258,6 +3258,21 @@ Heads up In the island Lock screen + OTP codes + Detect OTP codes + Show one-time codes from notifications with a copy button + Tap to copy + Tapping the peek copies the code. When off, codes open expanded with a Copy button + Dismiss notification after copy + Show codes from any app + Codes appear even from apps outside the notification filter + Show codes for + OTP code + Copy code + Dismiss + Code copied + Show message + One-time code Only show notifications from the apps you pick Ignore notifications that arrive without sound Follow Do Not Disturb diff --git a/app/src/test/java/com/sameerasw/essentials/island/OtpExtractorTest.kt b/app/src/test/java/com/sameerasw/essentials/island/OtpExtractorTest.kt new file mode 100644 index 000000000..c315aeca5 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/island/OtpExtractorTest.kt @@ -0,0 +1,76 @@ +package com.sameerasw.essentials.island + +import com.sameerasw.essentials.island.plugins.notifications.OtpExtractor +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class OtpExtractorTest { + private fun code(text: String) = OtpExtractor.extract(text) + + @Test + fun findsCommonEnglishCodes() { + assertEquals("482913", code("G-482913 is your Google verification code.")) + assertEquals("482913", code("Your WhatsApp code: 482-913. Don't share this code with others")) + assertEquals("482913", code("Your Uber code is 482 913. Never share this code.")) + assertEquals("4829", code("Use 4829 as your one-time passcode")) + assertEquals("48291374", code("Microsoft account security code: 48291374")) + assertEquals("AB12CD", code("Your login code is AB12CD")) + assertEquals("482913", code("482913 is your Instagram code. Don't share it.")) + } + + @Test + fun findsSpanishAndPortugueseCodes() { + assertEquals("482913", code("Tu código de verificación es 482913")) + assertEquals("739104", code("BBVA: Tu clave de operación es 739104. No la compartas.")) + assertEquals("1234", code("Tu codigo OTP es 1234")) + assertEquals("552190", code("Seu código de verificação é 552190")) + } + + @Test + fun picksCodeOverOtherNumbers() { + assertEquals("482913", code("Code 482913 for your card ending in 1234")) + assertEquals("739104", code("Your code is 739104. Purchase of $1,250.00 at Amazon")) + assertEquals("903311", code("Pedido 12345678: tu código de entrega es 903311")) + assertEquals("2024", code("Your PIN code 2024")) + } + + @Test + fun findsDomainBoundAndShortCodes() { + assertEquals("123456", code("Your Apple Account code is: 123456. Don't share it with anyone.\n\n@apple.com #123456 %apple.com")) + assertEquals("847261", code("847261 is your login code\n\n@example.com #847261")) + assertEquals("FR4NC", code("Your Steam Guard code is FR4NC")) + assertEquals("X7K2", code("Your verification code: X7K2")) + assertEquals("592013", code("Tu token de acceso es 592013")) + assertEquals("4821", code("Tu NIP dinámico es 4821")) + assertEquals("482913", code("<#> Your App verification code is: 482913 FA+9qCX9VSu")) + } + + @Test + fun findsCodesInEmailNotifications() { + // Gmail-style: subject, then the snippet + assertEquals("639201", code("Your Slack confirmation code\nConfirm your email address. Your confirmation code is below — enter it in your browser window. 639201")) + assertEquals("481920", code("481920 is your Microsoft account verification code")) + assertEquals("773810", code("Verify your sign-in\nHi Isaac, use this code to finish signing in: 773810. It expires in 10 minutes. © 2026 Example Inc.")) + assertNull(code("Your receipt from Example\nThanks for your order 2026-1043. Total $54.20. Use promo code SAVE10 next time")) + } + + @Test + fun ignoresMessagesWithoutCodes() { + assertNull(code("Your order #123456 has shipped")) + assertNull(code("You paid $1234 to Maria")) + assertNull(code("Meeting moved to 2026, see you there")) + assertNull(code("Hey, call me at 5551234567")) + assertNull(code("Code review at 14:30 in room 4")) + assertNull(code("The code for 2026 changed")) + assertNull(code("Tu pedido 98765432 está en camino")) + assertNull(code("")) + } + + @Test + fun ignoresAmountsAndDecimalsNearKeywords() { + assertNull(code("Promo code applied: you saved 1,500.00")) + assertNull(code("Verification of payment MXN 4500 completed")) + assertNull(code("Your code discount is 15%")) + } +} From a14907bac097a7ad079794aa272a3e981458bd29 Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Wed, 14 Oct 2026 22:36:06 -0700 Subject: [PATCH 02/24] feat(island): missing shizuku permissions --- .../features/display/IslandSubSettingsUI.kt | 19 +++++++++++++ .../essentials/utils/PermissionGrantUtil.kt | 28 +++++++++++++++++++ app/src/main/res/values/strings.xml | 2 ++ 3 files changed, 49 insertions(+) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt index 50d89222c..109347b5d 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt @@ -67,6 +67,7 @@ import com.sameerasw.essentials.ui.features.display.sheets.StatusGlanceCalendarO import com.sameerasw.essentials.ui.features.display.sheets.rememberIslandShowsWhileLocked import com.sameerasw.essentials.ui.modifiers.highlight import com.sameerasw.essentials.utils.HapticUtil +import com.sameerasw.essentials.utils.PermissionGrantUtil import com.sameerasw.essentials.utils.PermissionUIHelper import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.utils.ShellUtils @@ -869,6 +870,7 @@ fun IslandNotificationSettingsUI( var otpTimeoutMs by remember { mutableStateOf(SettingsRepository(context).getIslandOtpTimeoutMs()) } + var otpReadable by remember { mutableStateOf(PermissionGrantUtil.hasSensitiveNotifications(context)) } var concealed by remember { mutableStateOf( SettingsRepository(context).getBoolean(SettingsRepository.KEY_ISLAND_NOTIF_CONCEAL_LOCKED, false) @@ -1092,6 +1094,23 @@ fun IslandNotificationSettingsUI( exit = fadeOut() + shrinkVertically(), ) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (!otpReadable) { + val featureName = stringResource(R.string.island_otp_section) + IconToggleItem( + iconRes = R.drawable.rounded_vpn_key_24, + title = stringResource(R.string.island_otp_grant_title), + description = stringResource(R.string.island_otp_grant_desc), + showToggle = false, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + Thread { + PermissionGrantUtil.grantSensitiveNotifications(context, featureName) + val granted = PermissionGrantUtil.hasSensitiveNotifications(context) + view.post { otpReadable = granted } + }.start() + }, + ) + } IslandPrefToggle( settingKey = SettingsRepository.KEY_ISLAND_OTP_TAP_TO_COPY, iconRes = R.drawable.rounded_content_copy_24, diff --git a/app/src/main/java/com/sameerasw/essentials/utils/PermissionGrantUtil.kt b/app/src/main/java/com/sameerasw/essentials/utils/PermissionGrantUtil.kt index b7fb3afa2..f31b88944 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/PermissionGrantUtil.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/PermissionGrantUtil.kt @@ -9,9 +9,11 @@ package com.sameerasw.essentials.utils +import android.app.AppOpsManager import android.content.Context import android.content.pm.PackageManager import android.os.Build +import android.os.Process import android.provider.Settings import com.sameerasw.essentials.services.NotificationListener import com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService @@ -48,9 +50,35 @@ object PermissionGrantUtil { } ShellUtils.runCommandWithOutput(context, commands.joinToString("; ") { "$it >/dev/null 2>&1" }, notifyOnError = false) + if (!hasSensitiveNotifications(context)) grantSensitiveNotifications(context) return true } + private const val OP_SENSITIVE_NOTIFICATIONS = "android:receive_sensitive_notifications" + + // Android 15+ redacts OTP notifications for listeners without this op + fun hasSensitiveNotifications(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return true + return try { + val appOps = context.getSystemService(AppOpsManager::class.java) + appOps.unsafeCheckOpNoThrow(OP_SENSITIVE_NOTIFICATIONS, Process.myUid(), context.packageName) == AppOpsManager.MODE_ALLOWED + } catch (_: Exception) { + true + } + } + + // Trust is resolved when the listener binds, so rebind after granting + fun grantSensitiveNotifications(context: Context, featureName: String? = null) { + val pkg = context.packageName + val listener = "$pkg/${NotificationListener::class.java.name}" + ShellUtils.runCommandWithOutput( + context, + "appops set $pkg RECEIVE_SENSITIVE_NOTIFICATIONS allow; " + + "cmd notification disallow_listener $listener; cmd notification allow_listener $listener", + featureName = featureName, + ) + } + private fun requestedPermissions(context: Context): List = try { val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { context.packageManager.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong())) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 60773a072..ca711827f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3273,6 +3273,8 @@ Code copied Show message One-time code + Allow reading codes + Android hides one-time codes from apps. Tap to grant access with Shizuku or root Only show notifications from the apps you pick Ignore notifications that arrive without sound Follow Do Not Disturb From e224da672734b048ecf9fa658d66918fcef26454 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 09:29:55 +0530 Subject: [PATCH 03/24] feat: add customizable low and critical battery level thresholds for duo overlay --- .../data/repository/SettingsRepository.kt | 11 +++ .../services/handlers/DuoOverlayHandler.kt | 2 + .../sheets/DuoBatteryOptionsBottomSheet.kt | 73 +++++++++++++++---- .../essentials/utils/DuoBatteryLevels.kt | 24 ++++++ .../essentials/utils/DuoOverlayView.kt | 30 ++++++-- .../essentials/viewmodels/MainViewModel.kt | 27 +++++++ app/src/main/res/values/strings.xml | 6 +- .../essentials/utils/DuoBatteryLevelsTest.kt | 38 ++++++++++ 8 files changed, 188 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/utils/DuoBatteryLevels.kt create mode 100644 app/src/test/java/com/sameerasw/essentials/utils/DuoBatteryLevelsTest.kt diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 73f75474f..8e47b8f30 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -26,6 +26,7 @@ import com.sameerasw.essentials.domain.model.AppSelection import com.sameerasw.essentials.domain.model.BackGestureAppConfig import com.sameerasw.essentials.domain.model.AppTag import com.sameerasw.essentials.domain.model.DnsPreset +import com.sameerasw.essentials.utils.DuoBatteryLevels import com.sameerasw.essentials.domain.model.HilightEffect import com.sameerasw.essentials.domain.model.HilightProgressColorMode import com.sameerasw.essentials.domain.model.NotificationLightingColorMode @@ -493,6 +494,8 @@ class SettingsRepository( const val KEY_ISLAND_BATTERY_IDLE_COLOR = "island_battery_idle_color" const val KEY_DUO_BATTERY_CRITICAL_COLOR_ENABLED = "duo_battery_critical_color_enabled" const val KEY_DUO_BATTERY_CRITICAL_COLOR = "duo_battery_critical_color" + const val KEY_DUO_BATTERY_LOW_LEVEL = "duo_battery_low_level" + const val KEY_DUO_BATTERY_CRITICAL_LEVEL = "duo_battery_critical_level" const val KEY_DUO_SHOW_NETWORKS = "duo_show_networks" const val KEY_DUO_DIFFERENTIATE_WIFI = "duo_differentiate_wifi" const val KEY_DUO_SHOW_TIME = "duo_show_time" @@ -3903,6 +3906,14 @@ class SettingsRepository( fun getDuoBatteryCriticalColor(): String = getString(KEY_DUO_BATTERY_CRITICAL_COLOR, "#F44336") ?: "#F44336" fun setDuoBatteryCriticalColor(colorHex: String) = putString(KEY_DUO_BATTERY_CRITICAL_COLOR, colorHex) + fun getDuoBatteryLowLevel(): Int = getInt(KEY_DUO_BATTERY_LOW_LEVEL, DuoBatteryLevels.DEFAULT_LOW) + fun getDuoBatteryCriticalLevel(): Int = getInt(KEY_DUO_BATTERY_CRITICAL_LEVEL, DuoBatteryLevels.DEFAULT_CRITICAL) + + fun setDuoBatteryLevels(low: Int, critical: Int) { + putInt(KEY_DUO_BATTERY_LOW_LEVEL, low) + putInt(KEY_DUO_BATTERY_CRITICAL_LEVEL, critical) + } + fun isDuoShowNetworksEnabled(): Boolean = getBoolean(KEY_DUO_SHOW_NETWORKS, true) fun setDuoShowNetworksEnabled(enabled: Boolean) = putBoolean(KEY_DUO_SHOW_NETWORKS, enabled) diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt index 7cd3a6ead..5c315b16d 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt @@ -923,6 +923,8 @@ class DuoOverlayHandler( try { this.batteryPowerSaveColor = Color.parseColor(settingsRepository.getDuoBatteryPowerSaveColor()) } catch (_: Exception) {} + this.batteryLowLevel = settingsRepository.getDuoBatteryLowLevel() + this.batteryCriticalLevel = settingsRepository.getDuoBatteryCriticalLevel() this.isBatteryLowColorEnabled = settingsRepository.isDuoBatteryLowColorEnabled() try { this.batteryLowColor = Color.parseColor(settingsRepository.getDuoBatteryLowColor()) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/DuoBatteryOptionsBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/DuoBatteryOptionsBottomSheet.kt index f986003fc..fd4902f09 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/DuoBatteryOptionsBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/DuoBatteryOptionsBottomSheet.kt @@ -27,10 +27,12 @@ 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.components.sliders.ConfigSliderItem import com.sameerasw.essentials.ui.core.cards.IconToggleItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.pickers.ColorSwatchPicker import com.sameerasw.essentials.ui.core.sheets.EssentialsBottomSheet +import com.sameerasw.essentials.utils.DuoBatteryLevels import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.viewmodels.MainViewModel @@ -38,10 +40,10 @@ import com.sameerasw.essentials.viewmodels.MainViewModel fun BatteryColorOptions( viewModel: MainViewModel, showIdleColor: Boolean = false, + showLevels: Boolean = false, ) { val view = LocalView.current Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - // Low Battery (< 20%) RoundedCardContainer( spacing = 2.dp, cornerRadius = 24.dp, @@ -60,16 +62,25 @@ fun BatteryColorOptions( enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { - ColorSwatchPicker( - selectedColorHex = viewModel.duoBatteryLowColor.value, - onColorSelected = { hex -> - viewModel.setDuoBatteryLowColor(hex) - }, - ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (showLevels) { + LevelSlider( + title = stringResource(R.string.duo_battery_low_level_title), + level = viewModel.duoBatteryLowLevel.intValue, + range = (DuoBatteryLevels.MIN + 1)..DuoBatteryLevels.MAX, + onLevelChange = viewModel::setDuoBatteryLowLevel, + ) + } + ColorSwatchPicker( + selectedColorHex = viewModel.duoBatteryLowColor.value, + onColorSelected = { hex -> + viewModel.setDuoBatteryLowColor(hex) + }, + ) + } } } - // Critically Low Battery (< 10%) RoundedCardContainer( spacing = 2.dp, cornerRadius = 24.dp, @@ -88,12 +99,22 @@ fun BatteryColorOptions( enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { - ColorSwatchPicker( - selectedColorHex = viewModel.duoBatteryCriticalColor.value, - onColorSelected = { hex -> - viewModel.setDuoBatteryCriticalColor(hex) - }, - ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (showLevels) { + LevelSlider( + title = stringResource(R.string.duo_battery_critical_level_title), + level = viewModel.duoBatteryCriticalLevel.intValue, + range = DuoBatteryLevels.MIN..(DuoBatteryLevels.MAX - 1), + onLevelChange = viewModel::setDuoBatteryCriticalLevel, + ) + } + ColorSwatchPicker( + selectedColorHex = viewModel.duoBatteryCriticalColor.value, + onColorSelected = { hex -> + viewModel.setDuoBatteryCriticalColor(hex) + }, + ) + } } } @@ -185,6 +206,28 @@ fun BatteryColorOptions( } } +@Composable +private fun LevelSlider( + title: String, + level: Int, + range: IntRange, + onLevelChange: (Int) -> Unit, +) { + val view = LocalView.current + ConfigSliderItem( + title = title, + value = level.toFloat(), + onValueChange = { + HapticUtil.performUIHaptic(view) + onLevelChange(it.toInt().coerceIn(range)) + }, + valueRange = range.first.toFloat()..range.last.toFloat(), + increment = 1f, + iconRes = R.drawable.rounded_percent_24, + valueFormatter = { "${it.toInt()}%" }, + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun DuoBatteryOptionsBottomSheet( @@ -209,7 +252,7 @@ fun DuoBatteryOptionsBottomSheet( modifier = Modifier.padding(start = 8.dp, bottom = 4.dp), ) - BatteryColorOptions(viewModel) + BatteryColorOptions(viewModel, showLevels = true) } } } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/DuoBatteryLevels.kt b/app/src/main/java/com/sameerasw/essentials/utils/DuoBatteryLevels.kt new file mode 100644 index 000000000..0c7269722 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/DuoBatteryLevels.kt @@ -0,0 +1,24 @@ +package com.sameerasw.essentials.utils + +object DuoBatteryLevels { + const val DEFAULT_LOW = 20 + const val DEFAULT_CRITICAL = 10 + const val MIN = 1 + const val MAX = 99 + + fun withLow( + low: Int, + critical: Int, + ): Pair { + val newLow = low.coerceIn(MIN + 1, MAX) + return newLow to critical.coerceIn(MIN, newLow - 1) + } + + fun withCritical( + critical: Int, + low: Int, + ): Pair { + val newCritical = critical.coerceIn(MIN, MAX - 1) + return low.coerceIn(newCritical + 1, MAX) to newCritical + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt b/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt index 5d75c48b4..52562c277 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt @@ -201,8 +201,8 @@ class DuoOverlayView(context: Context) : View(context) { fun isBatteryColoredInstanceActive(): Boolean { if (isCharging && isBatteryChargingColorEnabled) return true if (isPowerSaveMode && isBatteryPowerSaveColorEnabled) return true - if (batteryLevel <= 10 && isBatteryCriticalColorEnabled) return true - if (batteryLevel <= 20 && isBatteryLowColorEnabled) return true + if (batteryLevel <= batteryCriticalLevel && isBatteryCriticalColorEnabled) return true + if (batteryLevel <= batteryLowLevel && isBatteryLowColorEnabled) return true return false } @@ -363,6 +363,24 @@ class DuoOverlayView(context: Context) : View(context) { } } + var batteryLowLevel: Int = DuoBatteryLevels.DEFAULT_LOW + set(value) { + if (field != value) { + field = value + animateThemeChange() + updateBatteryPercentageVisibility() + } + } + + var batteryCriticalLevel: Int = DuoBatteryLevels.DEFAULT_CRITICAL + set(value) { + if (field != value) { + field = value + animateThemeChange() + updateBatteryPercentageVisibility() + } + } + var isBatteryLowColorEnabled: Boolean = true set(value) { if (field != value) { @@ -1119,13 +1137,13 @@ class DuoOverlayView(context: Context) : View(context) { val dimTrack = Color.argb(50, Color.red(saveAccent), Color.green(saveAccent), Color.blue(saveAccent)) return Triple(dimTrack, dimProgress, dimProgress) } - if ((showBattery || showBatteryPercentage) && batteryLevel <= 10 && isBatteryCriticalColorEnabled) { + if ((showBattery || showBatteryPercentage) && batteryLevel <= batteryCriticalLevel && isBatteryCriticalColorEnabled) { val critAccent = getThemeAdjustedColor(batteryCriticalColor) val dimProgress = Color.argb(160, Color.red(critAccent), Color.green(critAccent), Color.blue(critAccent)) val dimTrack = Color.argb(50, Color.red(critAccent), Color.green(critAccent), Color.blue(critAccent)) return Triple(dimTrack, dimProgress, dimProgress) } - if ((showBattery || showBatteryPercentage) && batteryLevel <= 20 && isBatteryLowColorEnabled) { + if ((showBattery || showBatteryPercentage) && batteryLevel <= batteryLowLevel && isBatteryLowColorEnabled) { val lowAccent = getThemeAdjustedColor(batteryLowColor) val dimProgress = Color.argb(160, Color.red(lowAccent), Color.green(lowAccent), Color.blue(lowAccent)) val dimTrack = Color.argb(50, Color.red(lowAccent), Color.green(lowAccent), Color.blue(lowAccent)) @@ -1173,14 +1191,14 @@ class DuoOverlayView(context: Context) : View(context) { return Triple(track, saveAccent, saveAccent) } - if ((showBattery || showBatteryPercentage) && batteryLevel <= 10 && isBatteryCriticalColorEnabled) { + if ((showBattery || showBatteryPercentage) && batteryLevel <= batteryCriticalLevel && isBatteryCriticalColorEnabled) { val critAccent = getThemeAdjustedColor(batteryCriticalColor) val trackAlpha = if (isDarkTheme) 90 else 110 val track = Color.argb(trackAlpha, Color.red(critAccent), Color.green(critAccent), Color.blue(critAccent)) return Triple(track, critAccent, critAccent) } - if ((showBattery || showBatteryPercentage) && batteryLevel <= 20 && isBatteryLowColorEnabled) { + if ((showBattery || showBatteryPercentage) && batteryLevel <= batteryLowLevel && isBatteryLowColorEnabled) { val lowAccent = getThemeAdjustedColor(batteryLowColor) val trackAlpha = if (isDarkTheme) 90 else 110 val track = Color.argb(trackAlpha, Color.red(lowAccent), Color.green(lowAccent), Color.blue(lowAccent)) 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 f4d353b74..a1dbf26cc 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -84,6 +84,7 @@ import com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService import com.sameerasw.essentials.utils.AppIconUtil import com.sameerasw.essentials.utils.AppUtil import com.sameerasw.essentials.utils.DeviceUtils +import com.sameerasw.essentials.utils.DuoBatteryLevels import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.utils.RefreshRateUtils import com.sameerasw.essentials.utils.RootUtils @@ -188,6 +189,8 @@ class MainViewModel : ViewModel() { val duoBatteryPowerSaveColor = mutableStateOf("#FF9800") val isDuoBatteryLowColorEnabled = mutableStateOf(true) val duoBatteryLowColor = mutableStateOf("#FFEB3B") + val duoBatteryLowLevel = mutableIntStateOf(DuoBatteryLevels.DEFAULT_LOW) + val duoBatteryCriticalLevel = mutableIntStateOf(DuoBatteryLevels.DEFAULT_CRITICAL) val isIslandBatteryIdleColorEnabled = mutableStateOf(false) val islandBatteryIdleColor = mutableStateOf("#FFFFFF") val isDuoBatteryCriticalColorEnabled = mutableStateOf(true) @@ -834,6 +837,12 @@ class MainViewModel : ViewModel() { SettingsRepository.KEY_DUO_BATTERY_CRITICAL_COLOR -> duoBatteryCriticalColor.value = settingsRepository.getDuoBatteryCriticalColor() + SettingsRepository.KEY_DUO_BATTERY_LOW_LEVEL -> + duoBatteryLowLevel.intValue = settingsRepository.getDuoBatteryLowLevel() + + SettingsRepository.KEY_DUO_BATTERY_CRITICAL_LEVEL -> + duoBatteryCriticalLevel.intValue = settingsRepository.getDuoBatteryCriticalLevel() + SettingsRepository.KEY_DUO_SHOW_NETWORKS -> isDuoShowNetworks.value = settingsRepository.isDuoShowNetworksEnabled() @@ -2417,6 +2426,8 @@ class MainViewModel : ViewModel() { islandBatteryIdleColor.value = settingsRepository.getIslandBatteryIdleColor() isDuoBatteryCriticalColorEnabled.value = settingsRepository.isDuoBatteryCriticalColorEnabled() duoBatteryCriticalColor.value = settingsRepository.getDuoBatteryCriticalColor() + duoBatteryLowLevel.intValue = settingsRepository.getDuoBatteryLowLevel() + duoBatteryCriticalLevel.intValue = settingsRepository.getDuoBatteryCriticalLevel() isDuoShowNetworks.value = settingsRepository.isDuoShowNetworksEnabled() isDuoDifferentiateWifi.value = settingsRepository.isDuoDifferentiateWifiEnabled() isDuoShowTime.value = settingsRepository.isDuoShowTimeEnabled() @@ -5495,6 +5506,22 @@ class MainViewModel : ViewModel() { settingsRepository.setDuoBatteryCriticalColor(colorHex) } + fun setDuoBatteryLowLevel(level: Int) { + val (low, critical) = DuoBatteryLevels.withLow(level, duoBatteryCriticalLevel.intValue) + applyDuoBatteryLevels(low, critical) + } + + fun setDuoBatteryCriticalLevel(level: Int) { + val (low, critical) = DuoBatteryLevels.withCritical(level, duoBatteryLowLevel.intValue) + applyDuoBatteryLevels(low, critical) + } + + private fun applyDuoBatteryLevels(low: Int, critical: Int) { + duoBatteryLowLevel.intValue = low + duoBatteryCriticalLevel.intValue = critical + settingsRepository.setDuoBatteryLevels(low, critical) + } + fun setDuoShowNetworks(enabled: Boolean) { isDuoShowNetworks.value = enabled settingsRepository.setDuoShowNetworksEnabled(enabled) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9039340bc..74b9ca9da 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3020,8 +3020,10 @@ Idle color Auto charging speed Battery saver - Low battery (<20%) - Critically low battery (<10%) + Low battery + Low battery level + Critically low battery + Critical battery level Configuration Select apps to skip Networks diff --git a/app/src/test/java/com/sameerasw/essentials/utils/DuoBatteryLevelsTest.kt b/app/src/test/java/com/sameerasw/essentials/utils/DuoBatteryLevelsTest.kt new file mode 100644 index 000000000..11f0bf53e --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/utils/DuoBatteryLevelsTest.kt @@ -0,0 +1,38 @@ +package com.sameerasw.essentials.utils + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DuoBatteryLevelsTest { + @Test + fun lowAboveCriticalIsKept() { + assertEquals(30 to 10, DuoBatteryLevels.withLow(30, 10)) + } + + @Test + fun lowBelowCriticalPullsCriticalDown() { + assertEquals(8 to 7, DuoBatteryLevels.withLow(8, 10)) + } + + @Test + fun lowIsClampedToRange() { + assertEquals(99 to 10, DuoBatteryLevels.withLow(150, 10)) + assertEquals(2 to 1, DuoBatteryLevels.withLow(-5, 10)) + } + + @Test + fun criticalAboveLowPushesLowUp() { + assertEquals(26 to 25, DuoBatteryLevels.withCritical(25, 20)) + } + + @Test + fun criticalBelowLowIsKept() { + assertEquals(20 to 15, DuoBatteryLevels.withCritical(15, 20)) + } + + @Test + fun criticalIsClampedToRange() { + assertEquals(99 to 98, DuoBatteryLevels.withCritical(500, 20)) + assertEquals(20 to 1, DuoBatteryLevels.withCritical(0, 20)) + } +} From 17e5096f3926458aa5291e84c0818c90725da36a Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 09:34:37 +0530 Subject: [PATCH 04/24] feat: add camera slot handling and safe width constraints to island UI templates --- .../essentials/island/ui/CompactTemplate.kt | 16 ++++++++++++++++ .../essentials/island/ui/IslandLayoutSpec.kt | 2 +- .../essentials/island/ui/LineTemplate.kt | 8 ++++---- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt index 00a6d3d6a..9d1390439 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt @@ -123,6 +123,22 @@ fun CompactTemplate( val squareCell = spec.cellSize.roundToPx() val opticalInset = (height * OPTICAL_INSET_RATIO).toInt() fun edgeInset(outer: Placeable?) = if (outer != null && outer.width > squareCell) opticalInset else 0 + if (cameraSlot == 0) { + val cells = left + right + onSlack(0) + if (cells.isEmpty()) return@Layout layout(0, height) {} + val pad = ((height - squareCell) / 2).coerceAtLeast(0) + val startInset = edgeInset(cells.first()) + val endInset = edgeInset(cells.last()) + val width = pad * 2 + startInset + endInset + content(cells) + return@Layout layout(width, height) { + var x = pad + startInset + cells.forEach { + it.place(x, (height - it.height) / 2) + x += it.width + spacing + } + } + } if (spec.growDirection != 0) { val cells = left + right val inset = edgeInset(if (spec.growDirection > 0) cells.lastOrNull() else cells.firstOrNull()) diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt index eab53a41d..8da6068eb 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt @@ -41,7 +41,7 @@ data class IslandLayoutSpec( val queueInline: Boolean = false, ) { val compactHeight: Dp get() = cameraDiameter + verticalGap * 2 - val cameraSlotWidth: Dp get() = cameraDiameter * cameraPresence + cameraGap * 2 + val cameraSlotWidth: Dp get() = (cameraDiameter + cameraGap * 2) * cameraPresence val cellSize: Dp get() = cameraDiameter val cellSpacing: Dp get() = cameraGap diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt index 2cc615815..18f443fd9 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt @@ -90,7 +90,7 @@ fun LineTemplate( endSlot() } } else { - if (spec.growDirection > 0) Spacer(Modifier.width(spec.cameraSlotWidth - spec.cameraGap)) + if (spec.growDirection > 0) Spacer(Modifier.width((spec.cameraSlotWidth - spec.cameraGap).coerceAtLeast(0.dp))) Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, @@ -102,7 +102,7 @@ fun LineTemplate( LineText(line.end, TextAlign.End, FontWeight.Normal, Modifier.weight(1f)) endSlot() } - if (spec.growDirection < 0) Spacer(Modifier.width(spec.cameraSlotWidth - spec.cameraGap)) + if (spec.growDirection < 0) Spacer(Modifier.width((spec.cameraSlotWidth - spec.cameraGap).coerceAtLeast(0.dp))) } } } @@ -176,11 +176,11 @@ private fun CondensedLine(line: LineContent, spec: IslandLayoutSpec, startInset: verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spec.cellSpacing), ) { - if (spec.growDirection > 0) Spacer(Modifier.width(spec.cameraSlotWidth - spec.cameraGap)) + if (spec.growDirection > 0) Spacer(Modifier.width((spec.cameraSlotWidth - spec.cameraGap).coerceAtLeast(0.dp))) leadingSlot() icon() title(natural.coerceAtMost(maxText)) - if (spec.growDirection < 0) Spacer(Modifier.width(spec.cameraSlotWidth - spec.cameraGap)) + if (spec.growDirection < 0) Spacer(Modifier.width((spec.cameraSlotWidth - spec.cameraGap).coerceAtLeast(0.dp))) } } } From 3d183b46ad7355d00902eae653aad8d5282e98c9 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 09:57:49 +0530 Subject: [PATCH 05/24] fix: battery alerts not respecting settings --- .../batteryalerts/BatteryAlertsPlugin.kt | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/batteryalerts/BatteryAlertsPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/batteryalerts/BatteryAlertsPlugin.kt index e2c52f44a..9eff4d669 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/batteryalerts/BatteryAlertsPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/batteryalerts/BatteryAlertsPlugin.kt @@ -24,6 +24,7 @@ class BatteryAlertsPlugin : BaseIslandPlugin() { override val id = "battery_alerts_plugin" override val settingKeys = setOf( + SettingsRepository.KEY_ISLAND_SHOW_BATTERY, SettingsRepository.KEY_ISLAND_SHOW_BATTERY_ALERTS, SettingsRepository.KEY_ISLAND_BATTERY_ALERT_CHARGING, SettingsRepository.KEY_ISLAND_BATTERY_ALERT_LOW, @@ -55,7 +56,10 @@ class BatteryAlertsPlugin : BaseIslandPlugin() { readBattery(intent) val newAlertState = currentAlertState() - if ((isCharging && !wasCharging && newAlertState == "charging") || (newAlertState != prevAlertState && newAlertState.isNotEmpty())) { + if (!enabled()) { + if (peeking) stopPeek() + render() + } else if ((isCharging && !wasCharging && newAlertState == "charging") || (newAlertState != prevAlertState && newAlertState.isNotEmpty())) { peekAlert() } else if (!peeking) { render() @@ -80,9 +84,18 @@ class BatteryAlertsPlugin : BaseIslandPlugin() { override fun refresh() { readBattery(null) + if (!enabled() && peeking) stopPeek() render() } + private fun enabled() = + settings.isIslandShowBatteryEnabled() && settings.isIslandShowBatteryAlertsEnabled() + + private fun stopPeek() { + ctx?.mainHandler?.removeCallbacks(endPeek) + peeking = false + } + private fun readBattery(intent: Intent?) { val batteryIntent = intent ?: context.registerReceiver( null, IntentFilter(Intent.ACTION_BATTERY_CHANGED) @@ -140,6 +153,7 @@ class BatteryAlertsPlugin : BaseIslandPlugin() { private fun peekAlert() { val c = ctx ?: return + if (!enabled()) return val duration = settings.getIslandPeekDurationMs() peeking = true render() @@ -154,8 +168,7 @@ class BatteryAlertsPlugin : BaseIslandPlugin() { return } - val masterEnabled = settings.getBoolean(SettingsRepository.KEY_ISLAND_SHOW_BATTERY_ALERTS, true) - if (!masterEnabled || !peeking || level < 0) { + if (!enabled() || !peeking || level < 0) { publish(emptyList()) return } From a815bebcaa979ada4e84b485b7a5dfd6899e83d6 Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Sat, 17 Oct 2026 21:34:34 -0700 Subject: [PATCH 06/24] feat(island): migrate island OTP settings to ViewModel and refine OTP extraction --- .../data/repository/SettingsRepository.kt | 1 + .../domain/registry/FeatureRegistry.kt | 15 ++ .../essentials/island/model/IslandModels.kt | 1 - .../notifications/NotificationsPlugin.kt | 22 ++- .../plugins/notifications/OtpClipboard.kt | 1 - .../plugins/notifications/OtpExpanded.kt | 1 - .../plugins/notifications/OtpExtractor.kt | 15 +- .../services/NotificationListener.kt | 3 +- .../features/display/IslandSubSettingsUI.kt | 145 ++++++++++-------- .../sheets/IslandLauncherOnlyToggle.kt | 2 - .../essentials/utils/PermissionGrantUtil.kt | 3 - .../essentials/viewmodels/MainViewModel.kt | 20 +++ app/src/main/res/values/strings.xml | 3 +- .../essentials/island/OtpExtractorTest.kt | 9 +- 14 files changed, 155 insertions(+), 86 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 41d3ccf8c..5e742e87b 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -4386,6 +4386,7 @@ class SettingsRepository( fun setIslandNotifTapToOpenEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_NOTIF_TAP_TO_OPEN, enabled) fun isIslandOtpEnabled(): Boolean = getBoolean(KEY_ISLAND_OTP_ENABLED, true) + fun setIslandOtpEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_OTP_ENABLED, enabled) fun isIslandOtpTapToCopyEnabled(): Boolean = getBoolean(KEY_ISLAND_OTP_TAP_TO_COPY, true) fun isIslandOtpDismissAfterCopyEnabled(): Boolean = getBoolean(KEY_ISLAND_OTP_DISMISS_AFTER_COPY, false) fun isIslandOtpBypassFilterEnabled(): Boolean = getBoolean(KEY_ISLAND_OTP_BYPASS_FILTER, true) 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 220349b97..70bfd51a4 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 @@ -1084,6 +1084,21 @@ object FeatureRegistry { R.string.island_catch_up_desc, "island_catch_up", ), + SearchSetting( + R.string.island_otp_enabled_title, + R.string.island_otp_enabled_desc, + "island_otp_enabled", + ), + SearchSetting( + R.string.island_otp_tap_to_copy_title, + R.string.island_otp_tap_to_copy_desc, + "island_otp_tap_to_copy", + ), + SearchSetting( + R.string.island_otp_bypass_filter_title, + R.string.island_otp_bypass_filter_desc, + "island_otp_bypass_filter", + ), ), ) { override fun isEnabled(viewModel: MainViewModel) = viewModel.isIslandEnabled.value 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 dcd16213d..0c4b80968 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 @@ -77,7 +77,6 @@ class InteractionOverrides( val onTap: (() -> Boolean)? = null, val onLongPress: (() -> Unit)? = null, val onExpandedTap: (() -> Boolean)? = null, - // Return true to consume a tap on the line peek instead of expanding val onLineTap: (() -> Boolean)? = null, ) { companion object { diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt index bfe3a77e2..e423e6cbf 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt @@ -155,8 +155,8 @@ class NotificationsPlugin : BaseIslandPlugin() { private val timeoutRunnable = Runnable { onTimeout() } private val catchUpRunnable = Runnable { clearAll() } - // Key of the alert whose code was just copied; shows the confirmation until copiedRunnable runs private var copiedKey: String? = null + private var postedWhileCopied = false private val copiedRunnable = Runnable { finishCopy() } private val listener = object : NotificationListener.NotificationAlertListener { @@ -219,7 +219,6 @@ class NotificationsPlugin : BaseIslandPlugin() { return if (currentAlert()?.let(::otpCodeOf) != null) settings.getIslandOtpTimeoutMs() else base } - // Same length and grouping as the code: 482 913 -> ••• ••• private fun maskOtp(code: String) = formatOtp(code).map { if (it == ' ') ' ' else '•' }.joinToString("") private fun concealed(): Boolean = @@ -235,6 +234,12 @@ class NotificationsPlugin : BaseIslandPlugin() { render() return } + if (copiedKey != null) { + alerts.addLast(alert) + while (alerts.size > MAX_QUEUE) alerts.removeFirst() + postedWhileCopied = true + return + } c.mainHandler.removeCallbacks(catchUpRunnable) catchingUp = false if (queueEnabled() && showingHere()) { @@ -280,9 +285,7 @@ class NotificationsPlugin : BaseIslandPlugin() { if (minimal()) return val otpNeedsExpand = currentAlert()?.let(::isOtp) == true && !settings.isIslandOtpTapToCopyEnabled() val lineAvailable = settings.isIslandLineStageEnabled() && settings.isIslandNotifCompactHeadsUpEnabled() && !otpNeedsExpand - markSeen() - c.request( if (lineAvailable) PluginRequest.Peek(ITEM_KEY, timeoutMs(), sticky = true) else PluginRequest.Expand(ITEM_KEY).also { autoExpanded = true }, @@ -357,6 +360,7 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun clearAll() { catchingUp = false copiedKey = null + postedWhileCopied = false cancelTimers() alerts.clear() seen.clear() @@ -666,6 +670,16 @@ class NotificationsPlugin : BaseIslandPlugin() { clearAll() return } + if (postedWhileCopied) { + postedWhileCopied = false + if (!queueEnabled()) while (alerts.size > 1) alerts.removeFirst() + currentIndex = alerts.lastIndex + manualPick = false + scheduleTimeout() + render() + popUp() + return + } scheduleTimeout() render() } diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpClipboard.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpClipboard.kt index bf8a83d3a..e24d719b3 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpClipboard.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpClipboard.kt @@ -12,7 +12,6 @@ object OtpClipboard { fun copy(context: Context, code: String): Boolean { val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return false val clip = ClipData.newPlainText(context.getString(R.string.island_otp_clip_label), code) - // Keeps keyboards and the system clipboard preview from showing the code clip.description.extras = PersistableBundle().apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExpanded.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExpanded.kt index e5825b6c5..f64620909 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExpanded.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExpanded.kt @@ -37,7 +37,6 @@ import com.sameerasw.essentials.island.ui.components.MarqueeText import com.sameerasw.essentials.island.ui.components.accentGlow import com.sameerasw.essentials.island.ui.components.cameraClearance -// Groups a code for reading: 1234 -> 1234, 482913 -> 482 913, 48291374 -> 4829 1374 fun formatOtp(code: String): String = when { code.length <= 5 -> code code.length == 6 -> "${code.take(3)} ${code.drop(3)}" diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExtractor.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExtractor.kt index 18c43d373..1a9a87923 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExtractor.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExtractor.kt @@ -1,7 +1,5 @@ package com.sameerasw.essentials.island.plugins.notifications -// Finds a one-time code in notification text. Returns null when unsure. -// A missed code beats a wrong one. object OtpExtractor { private val KEYWORD = Regex( "\\b(otp|code|codes|passcode|pin|2fa|mfa|verification|verify|one[- ]time|" + @@ -11,23 +9,25 @@ object OtpExtractor { ) private val CANDIDATES = listOf( - // G-482913 Regex("\\b[A-Z]{1,3}-(\\d{4,8})\\b"), - // 482 913 / 482-913 Regex("(? + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setIslandOtpEnabled(checked) + }, + modifier = Modifier.highlight(highlightSetting == "island_otp_enabled"), ) + AnimatedVisibility( + visible = otpEnabled && !otpReadable, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + val featureName = stringResource(R.string.island_otp_section) + IconToggleItem( + iconRes = R.drawable.rounded_vpn_key_24, + title = stringResource(R.string.island_otp_grant_title), + showToggle = false, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + scope.launch { + otpReadable = withContext(Dispatchers.IO) { + PermissionGrantUtil.grantSensitiveNotifications(context, featureName) + PermissionGrantUtil.hasSensitiveNotifications(context) + } + } + }, + ) + } AnimatedVisibility( visible = otpEnabled, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - if (!otpReadable) { - val featureName = stringResource(R.string.island_otp_section) - IconToggleItem( - iconRes = R.drawable.rounded_vpn_key_24, - title = stringResource(R.string.island_otp_grant_title), - description = stringResource(R.string.island_otp_grant_desc), - showToggle = false, - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - Thread { - PermissionGrantUtil.grantSensitiveNotifications(context, featureName) - val granted = PermissionGrantUtil.hasSensitiveNotifications(context) - view.post { otpReadable = granted } - }.start() - }, - ) - } - IslandPrefToggle( - settingKey = SettingsRepository.KEY_ISLAND_OTP_TAP_TO_COPY, - iconRes = R.drawable.rounded_content_copy_24, - title = stringResource(R.string.island_otp_tap_to_copy_title), - description = stringResource(R.string.island_otp_tap_to_copy_desc), - defaultValue = true, - ) - IslandPrefToggle( - settingKey = SettingsRepository.KEY_ISLAND_OTP_DISMISS_AFTER_COPY, - iconRes = R.drawable.rounded_check_circle_24, - title = stringResource(R.string.island_otp_dismiss_after_copy_title), - ) - ConfigSliderItem( - title = stringResource(R.string.island_otp_timeout_title), - value = OTP_TIMEOUT_STEPS_MS.indexOfClosest(otpTimeoutMs).toFloat(), - onValueChange = { - HapticUtil.performUIHaptic(view) - otpTimeoutMs = OTP_TIMEOUT_STEPS_MS[it.toInt().coerceIn(OTP_TIMEOUT_STEPS_MS.indices)] - SettingsRepository(context).setIslandOtpTimeoutMs(otpTimeoutMs) - }, - valueRange = 0f..OTP_TIMEOUT_STEPS_MS.lastIndex.toFloat(), - increment = 1f, - iconRes = R.drawable.rounded_timer_24, - valueFormatter = { formatCatchUp(OTP_TIMEOUT_STEPS_MS[it.toInt().coerceIn(OTP_TIMEOUT_STEPS_MS.indices)], "") }, - ) - IslandPrefToggle( - settingKey = SettingsRepository.KEY_ISLAND_OTP_BYPASS_FILTER, - iconRes = R.drawable.rounded_filter_alt_24, - title = stringResource(R.string.island_otp_bypass_filter_title), - description = stringResource(R.string.island_otp_bypass_filter_desc), - defaultValue = true, - ) - } + IslandPrefToggle( + settingKey = SettingsRepository.KEY_ISLAND_OTP_TAP_TO_COPY, + iconRes = R.drawable.rounded_content_copy_24, + title = stringResource(R.string.island_otp_tap_to_copy_title), + defaultValue = true, + modifier = Modifier.highlight(highlightSetting == "island_otp_tap_to_copy"), + ) + } + AnimatedVisibility( + visible = otpEnabled, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + IslandPrefToggle( + settingKey = SettingsRepository.KEY_ISLAND_OTP_DISMISS_AFTER_COPY, + iconRes = R.drawable.rounded_check_circle_24, + title = stringResource(R.string.island_otp_dismiss_after_copy_title), + ) + } + AnimatedVisibility( + visible = otpEnabled, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + ConfigSliderItem( + title = stringResource(R.string.island_otp_timeout_title), + value = OTP_TIMEOUT_STEPS_MS.indexOfClosest(viewModel.islandOtpTimeoutMs.longValue).toFloat(), + onValueChange = { + HapticUtil.performUIHaptic(view) + viewModel.setIslandOtpTimeoutMs(OTP_TIMEOUT_STEPS_MS[it.toInt().coerceIn(OTP_TIMEOUT_STEPS_MS.indices)]) + }, + valueRange = 0f..OTP_TIMEOUT_STEPS_MS.lastIndex.toFloat(), + increment = 1f, + iconRes = R.drawable.rounded_timer_24, + valueFormatter = { formatCatchUp(OTP_TIMEOUT_STEPS_MS[it.toInt().coerceIn(OTP_TIMEOUT_STEPS_MS.indices)], "") }, + ) + } + AnimatedVisibility( + visible = otpEnabled, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + IslandPrefToggle( + settingKey = SettingsRepository.KEY_ISLAND_OTP_BYPASS_FILTER, + iconRes = R.drawable.rounded_filter_alt_24, + title = stringResource(R.string.island_otp_bypass_filter_title), + defaultValue = true, + modifier = Modifier.highlight(highlightSetting == "island_otp_bypass_filter"), + ) } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandLauncherOnlyToggle.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandLauncherOnlyToggle.kt index f20a7992e..1fac44d56 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandLauncherOnlyToggle.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandLauncherOnlyToggle.kt @@ -43,7 +43,6 @@ fun IslandPrefToggle( iconRes: Int, title: String, modifier: Modifier = Modifier, - description: String? = null, defaultValue: Boolean = false, onChanged: (Boolean) -> Unit = {} ) { @@ -54,7 +53,6 @@ fun IslandPrefToggle( IconToggleItem( iconRes = iconRes, title = title, - description = description, isChecked = checked, onCheckedChange = { HapticUtil.performVirtualKeyHaptic(view) diff --git a/app/src/main/java/com/sameerasw/essentials/utils/PermissionGrantUtil.kt b/app/src/main/java/com/sameerasw/essentials/utils/PermissionGrantUtil.kt index f31b88944..a2581b3d3 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/PermissionGrantUtil.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/PermissionGrantUtil.kt @@ -50,13 +50,11 @@ object PermissionGrantUtil { } ShellUtils.runCommandWithOutput(context, commands.joinToString("; ") { "$it >/dev/null 2>&1" }, notifyOnError = false) - if (!hasSensitiveNotifications(context)) grantSensitiveNotifications(context) return true } private const val OP_SENSITIVE_NOTIFICATIONS = "android:receive_sensitive_notifications" - // Android 15+ redacts OTP notifications for listeners without this op fun hasSensitiveNotifications(context: Context): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return true return try { @@ -67,7 +65,6 @@ object PermissionGrantUtil { } } - // Trust is resolved when the listener binds, so rebind after granting fun grantSensitiveNotifications(context: Context, featureName: String? = null) { val pkg = context.packageName val listener = "$pkg/${NotificationListener::class.java.name}" 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 f4d353b74..784d27257 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -285,6 +285,8 @@ class MainViewModel : ViewModel() { val isIslandNotifTapToOpen = mutableStateOf(false) val isIslandCatchUpEnabled = mutableStateOf(true) val islandCatchUpTimeoutMs = mutableLongStateOf(10000L) + val isIslandOtpEnabled = mutableStateOf(true) + val islandOtpTimeoutMs = mutableLongStateOf(60_000L) val isIslandShowGlow = mutableStateOf(true) val isIslandBorderOutline = mutableStateOf(false) val islandBorderOutlineColor = mutableStateOf(SettingsRepository.ISLAND_BORDER_OUTLINE_DEFAULT_COLOR) @@ -988,6 +990,12 @@ class MainViewModel : ViewModel() { SettingsRepository.KEY_ISLAND_CATCH_UP_TIMEOUT_MS -> islandCatchUpTimeoutMs.longValue = settingsRepository.getIslandCatchUpTimeoutMs() + SettingsRepository.KEY_ISLAND_OTP_ENABLED -> + isIslandOtpEnabled.value = settingsRepository.isIslandOtpEnabled() + + SettingsRepository.KEY_ISLAND_OTP_TIMEOUT_MS -> + islandOtpTimeoutMs.longValue = settingsRepository.getIslandOtpTimeoutMs() + SettingsRepository.KEY_STATUS_GLANCE_ENABLED -> isStatusGlanceEnabled.value = settingsRepository.isStatusGlanceEnabled() @@ -2520,6 +2528,8 @@ class MainViewModel : ViewModel() { isIslandNotifTapToOpen.value = settingsRepository.isIslandNotifTapToOpenEnabled() isIslandCatchUpEnabled.value = settingsRepository.isIslandCatchUpEnabled() islandCatchUpTimeoutMs.longValue = settingsRepository.getIslandCatchUpTimeoutMs() + isIslandOtpEnabled.value = settingsRepository.isIslandOtpEnabled() + islandOtpTimeoutMs.longValue = settingsRepository.getIslandOtpTimeoutMs() isIslandShowGlow.value = settingsRepository.isIslandShowGlowEnabled() isIslandBorderOutline.value = settingsRepository.isIslandBorderOutlineEnabled() islandBorderOutlineColor.value = settingsRepository.getIslandBorderOutlineColor() @@ -6016,6 +6026,16 @@ class MainViewModel : ViewModel() { settingsRepository.setIslandCatchUpTimeoutMs(value) } + fun setIslandOtpEnabled(enabled: Boolean) { + isIslandOtpEnabled.value = enabled + settingsRepository.setIslandOtpEnabled(enabled) + } + + fun setIslandOtpTimeoutMs(value: Long) { + islandOtpTimeoutMs.longValue = value + settingsRepository.setIslandOtpTimeoutMs(value) + } + fun setIslandShowGlow(enabled: Boolean) { isIslandShowGlow.value = enabled settingsRepository.setIslandShowGlowEnabled(enabled) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd8709ed3..a0d7f6133 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3438,7 +3438,7 @@ Detect OTP codes Show one-time codes from notifications with a copy button Tap to copy - Tapping the peek copies the code. When off, codes open expanded with a Copy button + Tapping the peek copies the code Dismiss notification after copy Show codes from any app Codes appear even from apps outside the notification filter @@ -3450,7 +3450,6 @@ Show message One-time code Allow reading codes - Android hides one-time codes from apps. Tap to grant access with Shizuku or root Only show notifications from the apps you pick Ignore notifications that arrive without sound Follow Do Not Disturb diff --git a/app/src/test/java/com/sameerasw/essentials/island/OtpExtractorTest.kt b/app/src/test/java/com/sameerasw/essentials/island/OtpExtractorTest.kt index c315aeca5..6558c56b1 100644 --- a/app/src/test/java/com/sameerasw/essentials/island/OtpExtractorTest.kt +++ b/app/src/test/java/com/sameerasw/essentials/island/OtpExtractorTest.kt @@ -48,7 +48,6 @@ class OtpExtractorTest { @Test fun findsCodesInEmailNotifications() { - // Gmail-style: subject, then the snippet assertEquals("639201", code("Your Slack confirmation code\nConfirm your email address. Your confirmation code is below — enter it in your browser window. 639201")) assertEquals("481920", code("481920 is your Microsoft account verification code")) assertEquals("773810", code("Verify your sign-in\nHi Isaac, use this code to finish signing in: 773810. It expires in 10 minutes. © 2026 Example Inc.")) @@ -72,5 +71,13 @@ class OtpExtractorTest { assertNull(code("Promo code applied: you saved 1,500.00")) assertNull(code("Verification of payment MXN 4500 completed")) assertNull(code("Your code discount is 15%")) + assertNull(code("Do not share your OTP. Txn of 4500 INR on card XX12 at Amazon")) + assertNull(code("Your code: pay 4500 pesos before Friday")) + assertEquals("739104", code("OTP 739104 for txn of 4500 INR on card XX12")) + } + + @Test + fun domainBoundNeedsADigit() { + assertNull(code("Ping alex@acme.com #engineering before the code freeze")) } } From b872259f8d60b476c891ba35f9db3b5d23b83783 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 10:07:24 +0530 Subject: [PATCH 07/24] feat: add balance compact island setting --- .../data/repository/SettingsRepository.kt | 5 +++ .../domain/registry/FeatureRegistry.kt | 5 +++ .../island/service/IslandCoordinator.kt | 3 ++ .../island/state/CompactLayoutEngine.kt | 38 +++++++++++++++++-- .../island/state/IslandController.kt | 2 + .../essentials/island/ui/CompactTemplate.kt | 10 +++-- .../essentials/island/ui/IslandLayoutSpec.kt | 1 + .../essentials/island/ui/IslandRoot.kt | 12 +++++- .../ui/features/display/IslandSettingsUI.kt | 11 ++++++ .../essentials/viewmodels/MainViewModel.kt | 7 ++++ app/src/main/res/values/strings.xml | 1 + .../island/CompactLayoutEngineTest.kt | 14 +++++++ 12 files changed, 102 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 8e47b8f30..9d5b02c2d 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -590,6 +590,7 @@ class SettingsRepository( const val KEY_ISLAND_DISABLE_INTERACTIONS_IN_FULLSCREEN = "island_disable_interactions_in_fullscreen" const val KEY_ISLAND_LANDSCAPE_TOP_SPACING = "island_landscape_top_spacing" const val KEY_ISLAND_BOND_EDGE = "island_bond_edge" + const val KEY_ISLAND_BALANCE_COMPACT = "island_balance_compact" const val KEY_ISLAND_MAX_ITEMS = "island_max_items" const val KEY_ISLAND_ALWAYS_GESTURES = "island_always_gestures" const val KEY_ISLAND_DISMISS_ON_OUTSIDE = "island_dismiss_on_outside" @@ -4290,6 +4291,10 @@ class SettingsRepository( fun setIslandBondEdgeEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_BOND_EDGE, enabled) + fun isIslandBalanceCompactEnabled(): Boolean = getBoolean(KEY_ISLAND_BALANCE_COMPACT, true) + + fun setIslandBalanceCompactEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_BALANCE_COMPACT, enabled) + fun getIslandLandscapeTopSpacing(): Float = getFloat(KEY_ISLAND_LANDSCAPE_TOP_SPACING, 0f) fun setIslandLandscapeTopSpacing(value: Float) = putFloat(KEY_ISLAND_LANDSCAPE_TOP_SPACING, value) 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 220349b97..48ee80bea 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 @@ -821,6 +821,11 @@ object FeatureRegistry { "island_show_battery", R.array.keywords_battery, ), + SearchSetting( + R.string.island_balance_compact_title, + R.string.island_max_items_title, + "island_balance_compact", + ), SearchSetting( R.string.island_show_timers_title, R.string.island_stopwatch_default, 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 cc4b611cc..f6bf6197c 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 @@ -690,6 +690,7 @@ class IslandCoordinator( cameraPresence = if (geo.hasCamera) 1f else 0f, landscape = isLandscape, bondEdge = !geo.hasCamera && settings.isIslandBondEdgeEnabled(), + balanceCompact = settings.isIslandBalanceCompactEnabled(), maxExpandedHeight = if (isLandscape) ((geo.screenHeight - geo.surfaceTop) / density - 12f).dp else Dp.Unspecified, outlineColor = if (settings.isIslandBorderOutlineEnabled()) { runCatching { Color(AndroidColor.parseColor(settings.getIslandBorderOutlineColor())) } @@ -715,6 +716,7 @@ class IslandCoordinator( windowHost.updateGeometry(geo) controller.lineStageEnabled = settings.isIslandLineStageEnabled() controller.maxCells = settings.getIslandMaxItems() * 2 + controller.balanceCompact = settings.isIslandBalanceCompactEnabled() controller.relayout() controller.expandedTimeoutMs = settings.getIslandExpandedTimeoutMs() applyPreviewRing() @@ -834,6 +836,7 @@ class IslandCoordinator( SettingsRepository.KEY_ISLAND_HIDE_LANDSCAPE, SettingsRepository.KEY_ISLAND_LANDSCAPE_TOP_SPACING, SettingsRepository.KEY_ISLAND_BOND_EDGE, + SettingsRepository.KEY_ISLAND_BALANCE_COMPACT, SettingsRepository.KEY_ISLAND_MAX_ITEMS, ) } diff --git a/app/src/main/java/com/sameerasw/essentials/island/state/CompactLayoutEngine.kt b/app/src/main/java/com/sameerasw/essentials/island/state/CompactLayoutEngine.kt index bd8a08680..5cf440153 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/state/CompactLayoutEngine.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/state/CompactLayoutEngine.kt @@ -30,6 +30,7 @@ object CompactLayoutEngine { anchor: CameraAnchor = CameraAnchor.Center, maxCells: Int = MAX_CELLS, leftExtra: Int = 0, + balance: Boolean = false, ): CompactArrangement { val base = entries.filterNot { it.companionOnly } val companions = entries.filter { it.companionOnly } @@ -59,15 +60,46 @@ object CompactLayoutEngine { val twoCell = dynamic.filter { it.cellKeys.size == 2 } val oneCell = dynamic.filter { it.cellKeys.size == 1 } + val movable = mutableListOf() if (twoCell.isNotEmpty()) { after += twoCell[0].cellKeys.reversed() pinned.forEach { before += it.cellKeys } - twoCell.drop(1).forEach { before += it.cellKeys.reversed() } - oneCell.forEach { if (before.size + leftExtra <= after.size) before += it.cellKeys else after += it.cellKeys } + twoCell.drop(1).forEach { + before += it.cellKeys.reversed() + movable += it + } + oneCell.forEach { + if (before.size + leftExtra <= after.size) { + before += it.cellKeys + movable += it + } else { + after += it.cellKeys + } + } } else { pinned.getOrNull(0)?.let { before += it.cellKeys } pinned.getOrNull(1)?.let { after += it.cellKeys } - oneCell.forEach { if (after.size <= before.size + leftExtra) after += it.cellKeys else before += it.cellKeys } + oneCell.forEach { + if (after.size <= before.size + leftExtra) { + after += it.cellKeys + } else { + before += it.cellKeys + movable += it + } + } + } + + if (balance && leftExtra > 0) { + for (entry in movable.sortedByDescending { it.priority }) { + val size = entry.cellKeys.size + val left = before.size + leftExtra + val right = after.size + val gap = kotlin.math.abs(left - right) + val movedGap = kotlin.math.abs((left - size) - (right + size)) + if (movedGap > gap || (movedGap == gap && left <= right)) break + before.removeAll(entry.cellKeys.toSet()) + after += if (size == 2) entry.cellKeys.reversed() else entry.cellKeys + } } val solo = selected.singleOrNull() diff --git a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt index 437ea248d..92343e982 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt @@ -36,6 +36,7 @@ class IslandController( val state: StateFlow = _state.asStateFlow() var maxCells: Int = CompactLayoutEngine.MAX_CELLS + var balanceCompact: Boolean = true private var feedbackActive = false fun setFeedbackActive(active: Boolean) { @@ -297,6 +298,7 @@ class IslandController( anchorProvider(), maxCells = maxCells, leftExtra = if (bubbleOwner != null) 1 else 0, + balance = balanceCompact, ) val focusedKey = expandedKey ?: peekKey val stage = when { diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt index 9d1390439..9b61d6580 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt @@ -39,6 +39,7 @@ fun CompactTemplate( onCellTap: (String) -> Unit, onCellLongPress: (String) -> Unit, sideBubble: () -> Float = { 0f }, + bubbleExtent: () -> Int = { 0 }, onSlack: (Int) -> Unit = {}, ) { val cells = buildMap { @@ -161,9 +162,12 @@ fun CompactTemplate( val needRight = content(right) + rightInset + edge val t = if (hasCells) sideBubble().coerceIn(0f, 1f) else 0f val mirrored = maxOf(needLeft, needRight) - onSlack(needRight - needLeft) - val leftSide = (mirrored + (needLeft - mirrored) * t).roundToInt() - val rightSide = (mirrored + (needRight - mirrored) * t).roundToInt() + val extent = if (spec.balanceCompact) bubbleExtent().coerceAtLeast(0) else 0 + val balancedLeft = if (spec.balanceCompact) maxOf(needLeft, needRight - extent) else needLeft + val balancedRight = if (spec.balanceCompact) balancedLeft + extent else needRight + onSlack(if (spec.balanceCompact) extent else needRight - needLeft) + val leftSide = (mirrored + (balancedLeft - mirrored) * t).roundToInt() + val rightSide = (mirrored + (balancedRight - mirrored) * t).roundToInt() val width = leftSide + rightSide + cameraSlot layout(width, height) { var x = spacing + leftInset diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt index 8da6068eb..ec64f347d 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt @@ -26,6 +26,7 @@ data class IslandLayoutSpec( val maxExpandedHeight: Dp = Dp.Unspecified, val cameraPresence: Float = 1f, val bondEdge: Boolean = false, + val balanceCompact: Boolean = true, val landscape: Boolean = false, val cameraAnchor: CameraAnchor = CameraAnchor.Center, val outlineColor: Color? = null, diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt index 574331839..9064024f3 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt @@ -1139,6 +1139,7 @@ fun IslandRoot( onCellTap = ::handleTap, onCellLongPress = ::handleLongPress, sideBubble = ::sideBubbleProgress, + bubbleExtent = { bubbleGapPx + sideWidthLive() }, onSlack = { if (current) compactSlack.intValue = it }, ) } @@ -1167,7 +1168,14 @@ fun IslandRoot( }, contentAlignment = layerAlign, ) { - CompactTemplate(state = state, spec = spec, onCellTap = {}, onCellLongPress = {}, sideBubble = ::sideBubbleProgress) + CompactTemplate( + state = state, + spec = spec, + onCellTap = {}, + onCellLongPress = {}, + sideBubble = ::sideBubbleProgress, + bubbleExtent = { bubbleGapPx + sideWidthLive() }, + ) } } } @@ -1262,6 +1270,7 @@ private fun StageContent( onCellTap: (String) -> Unit = {}, onCellLongPress: (String) -> Unit = {}, sideBubble: () -> Float = { 0f }, + bubbleExtent: () -> Int = { 0 }, onSlack: (Int) -> Unit = {}, ) { val a = if (interactive) actions else NoActions @@ -1279,6 +1288,7 @@ private fun StageContent( onCellTap = { if (interactive) onCellTap(it) }, onCellLongPress = { if (interactive) onCellLongPress(it) }, sideBubble = sideBubble, + bubbleExtent = bubbleExtent, onSlack = onSlack, ) } 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 9f643d314..217d7cc39 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 @@ -239,6 +239,17 @@ fun IslandSettingsUI( modifier = Modifier.fillMaxWidth(), ) } + + IconToggleItem( + iconRes = R.drawable.rounded_format_align_center_24, + title = stringResource(R.string.island_balance_compact_title), + isChecked = viewModel.isIslandBalanceCompact.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setIslandBalanceCompact(checked) + }, + modifier = Modifier.highlight(highlightSetting == "island_balance_compact"), + ) } RoundedCardContainer( 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 a1dbf26cc..1bbf8b5ba 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -233,6 +233,7 @@ class MainViewModel : ViewModel() { val isIslandKeepInFullscreen = mutableStateOf(false) val isIslandDisableInteractionsInFullscreen = mutableStateOf(false) val isIslandBondEdge = mutableStateOf(false) + val isIslandBalanceCompact = mutableStateOf(true) val islandMaxItems = mutableStateOf(2) val isIslandAlwaysGestures = mutableStateOf(false) val islandLandscapeTopSpacing = mutableFloatStateOf(0f) @@ -2463,6 +2464,7 @@ class MainViewModel : ViewModel() { isIslandKeepInFullscreen.value = settingsRepository.isIslandKeepInFullscreenEnabled() isIslandDisableInteractionsInFullscreen.value = settingsRepository.isIslandDisableInteractionsInFullscreenEnabled() isIslandBondEdge.value = settingsRepository.isIslandBondEdgeEnabled() + isIslandBalanceCompact.value = settingsRepository.isIslandBalanceCompactEnabled() islandMaxItems.value = settingsRepository.getIslandMaxItems() isIslandAlwaysGestures.value = settingsRepository.isIslandAlwaysGesturesEnabled() islandLandscapeTopSpacing.floatValue = settingsRepository.getIslandLandscapeTopSpacing() @@ -5850,6 +5852,11 @@ class MainViewModel : ViewModel() { settingsRepository.setIslandMaxItems(value) } + fun setIslandBalanceCompact(enabled: Boolean) { + isIslandBalanceCompact.value = enabled + settingsRepository.setIslandBalanceCompactEnabled(enabled) + } + fun setIslandBondEdge(enabled: Boolean) { isIslandBondEdge.value = enabled settingsRepository.setIslandBondEdgeEnabled(enabled) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 74b9ca9da..a43bbba85 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3146,6 +3146,7 @@ Keep on landscape Landscape top spacing Bond with notch when inactive + Visually balance compact island Max items to show Always available gestures Keep the gesture area available even while island is hidden diff --git a/app/src/test/java/com/sameerasw/essentials/island/CompactLayoutEngineTest.kt b/app/src/test/java/com/sameerasw/essentials/island/CompactLayoutEngineTest.kt index b49e489bf..0f51f2151 100644 --- a/app/src/test/java/com/sameerasw/essentials/island/CompactLayoutEngineTest.kt +++ b/app/src/test/java/com/sameerasw/essentials/island/CompactLayoutEngineTest.kt @@ -61,4 +61,18 @@ class CompactLayoutEngineTest { @Test fun soloCellIgnoredWhenAnchoredToSide() = assertEquals("| notif.icon", visual(listOf(chatCatchUp), CameraAnchor.Start)) + + private fun withBubble(entries: List, balance: Boolean): String { + val a = CompactLayoutEngine.arrange(entries, CameraAnchor.Center, leftExtra = 1, balance = balance) + return (a.before.reversed() + "|" + a.after).joinToString(" ") + } + + @Test fun bubbleWithoutBalanceKeepsLeftItems() = + assertEquals("flash.icon notif.icon | media.eq media.art", withBubble(listOf(catchUp, flash, media), balance = false)) + + @Test fun bubbleBalanceMovesLowestPriorityRight() = + assertEquals("notif.icon | media.eq media.art flash.icon", withBubble(listOf(catchUp, flash, media), balance = true)) + + @Test fun balanceLeavesPinnedOnLeft() = + assertEquals("time | media.eq media.art flash.icon", withBubble(listOf(time, flash, media), balance = true)) } From 1698fa23d7e53625bb75ee38674208146e0d13d3 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 10:11:33 +0530 Subject: [PATCH 08/24] refactor: move island balance compact setting --- .../essentials/domain/registry/FeatureRegistry.kt | 10 +++++----- .../ui/features/display/IslandSettingsUI.kt | 11 ----------- .../ui/features/display/IslandSubSettingsUI.kt | 11 +++++++++++ 3 files changed, 16 insertions(+), 16 deletions(-) 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 48ee80bea..33ec65e0c 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 @@ -821,11 +821,6 @@ object FeatureRegistry { "island_show_battery", R.array.keywords_battery, ), - SearchSetting( - R.string.island_balance_compact_title, - R.string.island_max_items_title, - "island_balance_compact", - ), SearchSetting( R.string.island_show_timers_title, R.string.island_stopwatch_default, @@ -1013,6 +1008,11 @@ object FeatureRegistry { R.string.island_line_peek_desc, "island_line_stage_enabled", ), + SearchSetting( + R.string.island_balance_compact_title, + R.string.island_section_behavior, + "island_balance_compact", + ), ), ) { override fun isEnabled(viewModel: MainViewModel) = true 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 217d7cc39..9f643d314 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt @@ -239,17 +239,6 @@ fun IslandSettingsUI( modifier = Modifier.fillMaxWidth(), ) } - - IconToggleItem( - iconRes = R.drawable.rounded_format_align_center_24, - title = stringResource(R.string.island_balance_compact_title), - isChecked = viewModel.isIslandBalanceCompact.value, - onCheckedChange = { checked -> - HapticUtil.performVirtualKeyHaptic(view) - viewModel.setIslandBalanceCompact(checked) - }, - modifier = Modifier.highlight(highlightSetting == "island_balance_compact"), - ) } RoundedCardContainer( diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt index c096fb5af..0815d238a 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt @@ -605,6 +605,17 @@ fun IslandBehaviorSettingsUI( modifier = Modifier.highlight(highlightSetting == "island_line_stage_enabled"), ) + IconToggleItem( + iconRes = R.drawable.rounded_format_align_center_24, + title = stringResource(R.string.island_balance_compact_title), + isChecked = viewModel.isIslandBalanceCompact.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setIslandBalanceCompact(checked) + }, + modifier = Modifier.highlight(highlightSetting == "island_balance_compact"), + ) + Column( modifier = Modifier .fillMaxWidth() From 4e01c5e3fed594e6be39f5187d9e175278614245 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 10:17:06 +0530 Subject: [PATCH 09/24] feat: merge side camera peek in island text --- .../essentials/island/ui/LineTemplate.kt | 20 +++++++++++++++++-- .../island/ui/components/MarqueeText.kt | 12 +++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt index 18f443fd9..20d0f4947 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt @@ -27,7 +27,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.font.Font @@ -98,8 +102,12 @@ fun LineTemplate( ) { leadingSlot() if (line.showLeftIcon) icon() - LineText(line.start, TextAlign.Start, FontWeight.SemiBold, Modifier.weight(1f)) - LineText(line.end, TextAlign.End, FontWeight.Normal, Modifier.weight(1f)) + MarqueeText( + text = mergedLineText(line), + style = IslandTextStyles.line, + textAlign = TextAlign.Start, + modifier = Modifier.weight(1f), + ) endSlot() } if (spec.growDirection < 0) Spacer(Modifier.width((spec.cameraSlotWidth - spec.cameraGap).coerceAtLeast(0.dp))) @@ -203,6 +211,14 @@ private fun LeadingSlot(leading: LineLeading?, spec: IslandLayoutSpec) { } } +private fun mergedLineText(line: LineContent): AnnotatedString = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { append(line.start) } + if (line.end.isNotBlank()) { + if (line.start.isNotBlank()) append(" · ") + withStyle(SpanStyle(fontWeight = FontWeight.Normal)) { append(line.end) } + } +} + @Composable private fun LineText(text: String, align: TextAlign, weight: FontWeight, modifier: Modifier) { MarqueeText(text = text, style = IslandTextStyles.line.copy(fontWeight = weight), textAlign = align, modifier = modifier) diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/components/MarqueeText.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/components/MarqueeText.kt index 471c98670..75eee6484 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/components/MarqueeText.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/components/MarqueeText.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign @@ -29,6 +30,17 @@ fun MarqueeText( fadeStart: Boolean = true, fadeEnd: Boolean = true, fadeWidth: Dp = 14.dp, +) = MarqueeText(AnnotatedString(text), style, modifier, textAlign, fadeStart, fadeEnd, fadeWidth) + +@Composable +fun MarqueeText( + text: AnnotatedString, + style: TextStyle, + modifier: Modifier = Modifier, + textAlign: TextAlign = TextAlign.Start, + fadeStart: Boolean = true, + fadeEnd: Boolean = true, + fadeWidth: Dp = 14.dp, ) { val measurer = rememberTextMeasurer() BoxWithConstraints(modifier) { From b1c50b317837a8ed2a08b5380b9d0a449b74e63f Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 10:20:49 +0530 Subject: [PATCH 10/24] OTP: skip non-message categories, register remaining settings, refresh grant state on resume, re-allow listener if lost, test queue reindexing --- .../domain/registry/FeatureRegistry.kt | 10 +++++ .../notifications/NotificationsPlugin.kt | 6 +-- .../island/plugins/notifications/OtpQueue.kt | 11 +++++ .../services/NotificationListener.kt | 19 ++++++++- .../features/display/IslandSubSettingsUI.kt | 11 +++++ .../essentials/utils/PermissionGrantUtil.kt | 3 ++ .../essentials/island/OtpQueueTest.kt | 41 +++++++++++++++++++ 7 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpQueue.kt create mode 100644 app/src/test/java/com/sameerasw/essentials/island/OtpQueueTest.kt 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 1f1259b46..8775d2298 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 @@ -1099,6 +1099,16 @@ object FeatureRegistry { R.string.island_otp_tap_to_copy_desc, "island_otp_tap_to_copy", ), + SearchSetting( + R.string.island_otp_dismiss_after_copy_title, + R.string.island_otp_section, + "island_otp_dismiss_after_copy", + ), + SearchSetting( + R.string.island_otp_timeout_title, + R.string.island_otp_section, + "island_otp_timeout_ms", + ), SearchSetting( R.string.island_otp_bypass_filter_title, R.string.island_otp_bypass_filter_desc, diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt index e423e6cbf..cd9f665b5 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt @@ -661,11 +661,7 @@ class NotificationsPlugin : BaseIslandPlugin() { copiedKey = null if (settings.isIslandOtpDismissAfterCopyEnabled()) NotificationListener.dismissNotification(key) val index = alerts.indexOfFirst { it.key == key } - if (index >= 0) { - alerts.removeAt(index) - if (index < currentIndex) currentIndex-- - currentIndex = currentIndex.coerceIn(0, (alerts.size - 1).coerceAtLeast(0)) - } + if (index >= 0) currentIndex = alerts.removeKeepingCurrent(index, currentIndex) if (alerts.isEmpty()) { clearAll() return diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpQueue.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpQueue.kt new file mode 100644 index 000000000..10be9d1cb --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpQueue.kt @@ -0,0 +1,11 @@ +package com.sameerasw.essentials.island.plugins.notifications + +internal fun MutableList.removeKeepingCurrent( + index: Int, + currentIndex: Int, +): Int { + if (index !in indices) return currentIndex + removeAt(index) + val shifted = if (index < currentIndex) currentIndex - 1 else currentIndex + return shifted.coerceIn(0, (size - 1).coerceAtLeast(0)) +} 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 74707342c..b43c6c645 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt @@ -83,6 +83,18 @@ class NotificationListener : NotificationListenerService() { companion object { private const val PROGRESS_REFRESH_DEBOUNCE_MS = 250L private const val EXTRA_SHORT_CRITICAL_TEXT = "android.shortCriticalText" + private val OTP_SKIPPED_CATEGORIES = setOf( + Notification.CATEGORY_CALL, + Notification.CATEGORY_TRANSPORT, + Notification.CATEGORY_PROGRESS, + Notification.CATEGORY_NAVIGATION, + Notification.CATEGORY_ALARM, + Notification.CATEGORY_STOPWATCH, + Notification.CATEGORY_SERVICE, + Notification.CATEGORY_SYSTEM, + Notification.CATEGORY_STATUS, + Notification.CATEGORY_WORKOUT, + ) const val ACTION_LIKE_CURRENT_SONG = "com.sameerasw.essentials.ACTION_LIKE_CURRENT_SONG" const val ACTION_REQUEST_AMBIENT_GLANCE = @@ -307,6 +319,11 @@ class NotificationListener : NotificationListenerService() { return !ranking.matchesInterruptionFilter() || peekSuppressed } + private fun otpCodeOf(sbn: StatusBarNotification): String? { + if (sbn.notification.category in OTP_SKIPPED_CATEGORIES) return null + return OtpExtractor.extract(otpSourceText(sbn)) + } + private fun otpSourceText(sbn: StatusBarNotification): String { val extras = sbn.notification.extras ?: return "" return listOf( @@ -1072,7 +1089,7 @@ class NotificationListener : NotificationListenerService() { val alert = extractNotificationAlert(sbn)?.copy( isSilent = isSilentNotification(sbn, rankingMap), blockedByDnd = isBlockedByDnd(sbn, rankingMap), - otpCode = OtpExtractor.extract(otpSourceText(sbn)), + otpCode = otpCodeOf(sbn), ) val callHidden = CallNotificationParser.isCall(sbn) && !SettingsRepository(applicationContext).isIslandShowCallsEnabled() if (alert != null && !callHidden) { diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt index 052978510..1528607f2 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt @@ -33,6 +33,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -901,6 +904,14 @@ fun IslandNotificationSettingsUI( var showFilterAppsSheet by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() var otpReadable by remember { mutableStateOf(PermissionGrantUtil.hasSensitiveNotifications(context)) } + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) otpReadable = PermissionGrantUtil.hasSensitiveNotifications(context) + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } var showQueueSheet by remember { mutableStateOf( viewModel.isIslandNotifQueue.value && diff --git a/app/src/main/java/com/sameerasw/essentials/utils/PermissionGrantUtil.kt b/app/src/main/java/com/sameerasw/essentials/utils/PermissionGrantUtil.kt index a2581b3d3..231726804 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/PermissionGrantUtil.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/PermissionGrantUtil.kt @@ -74,6 +74,9 @@ object PermissionGrantUtil { "cmd notification disallow_listener $listener; cmd notification allow_listener $listener", featureName = featureName, ) + if (!PermissionUtils.hasNotificationListenerPermission(context)) { + ShellUtils.runCommandWithOutput(context, "cmd notification allow_listener $listener", featureName = featureName) + } } private fun requestedPermissions(context: Context): List = try { diff --git a/app/src/test/java/com/sameerasw/essentials/island/OtpQueueTest.kt b/app/src/test/java/com/sameerasw/essentials/island/OtpQueueTest.kt new file mode 100644 index 000000000..6b128edd0 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/island/OtpQueueTest.kt @@ -0,0 +1,41 @@ +package com.sameerasw.essentials.island + +import com.sameerasw.essentials.island.plugins.notifications.removeKeepingCurrent +import org.junit.Assert.assertEquals +import org.junit.Test + +class OtpQueueTest { + @Test + fun removingBeforeCurrentShiftsIndexDown() { + val list = mutableListOf("a", "b", "c") + assertEquals(1, list.removeKeepingCurrent(0, 2)) + assertEquals(listOf("b", "c"), list) + } + + @Test + fun removingCurrentKeepsIndexInRange() { + val list = mutableListOf("a", "b", "c") + assertEquals(1, list.removeKeepingCurrent(2, 2)) + assertEquals(listOf("a", "b"), list) + } + + @Test + fun removingAfterCurrentKeepsIndex() { + val list = mutableListOf("a", "b", "c") + assertEquals(0, list.removeKeepingCurrent(2, 0)) + } + + @Test + fun removingLastItemGivesZero() { + val list = mutableListOf("a") + assertEquals(0, list.removeKeepingCurrent(0, 0)) + assertEquals(emptyList(), list) + } + + @Test + fun outOfRangeIndexChangesNothing() { + val list = mutableListOf("a", "b") + assertEquals(1, list.removeKeepingCurrent(5, 1)) + assertEquals(listOf("a", "b"), list) + } +} From 4684ae3880a1cc5d4936398c17bf835cab17c082 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 10:25:11 +0530 Subject: [PATCH 11/24] OTP: off by default, register sensitive-notifications permission and gate enabling on it --- .../data/repository/SettingsRepository.kt | 2 +- .../essentials/domain/model/AppPermission.kt | 7 +++ .../domain/registry/PermissionRegistry.kt | 1 + .../features/display/IslandSubSettingsUI.kt | 57 ++++++------------- .../essentials/utils/ui/PermissionUIHelper.kt | 32 +++++++++++ .../essentials/viewmodels/MainViewModel.kt | 5 +- app/src/main/res/values/strings.xml | 3 + 7 files changed, 65 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 0ea400b7e..422a8c13b 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -4401,7 +4401,7 @@ class SettingsRepository( fun isIslandNotifTapToOpenEnabled(): Boolean = getBoolean(KEY_ISLAND_NOTIF_TAP_TO_OPEN, false) fun setIslandNotifTapToOpenEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_NOTIF_TAP_TO_OPEN, enabled) - fun isIslandOtpEnabled(): Boolean = getBoolean(KEY_ISLAND_OTP_ENABLED, true) + fun isIslandOtpEnabled(): Boolean = getBoolean(KEY_ISLAND_OTP_ENABLED, false) fun setIslandOtpEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_OTP_ENABLED, enabled) fun isIslandOtpTapToCopyEnabled(): Boolean = getBoolean(KEY_ISLAND_OTP_TAP_TO_COPY, true) fun isIslandOtpDismissAfterCopyEnabled(): Boolean = getBoolean(KEY_ISLAND_OTP_DISMISS_AFTER_COPY, false) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/AppPermission.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/AppPermission.kt index 0f4dec1c8..500e953bc 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/AppPermission.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/AppPermission.kt @@ -13,6 +13,7 @@ import android.content.Context import androidx.annotation.DrawableRes import androidx.annotation.StringRes import com.sameerasw.essentials.R +import com.sameerasw.essentials.utils.PermissionGrantUtil import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.utils.ShellUtils @@ -131,6 +132,11 @@ enum class AppPermission( iconRes = R.drawable.rounded_image_24, aliases = listOf("READ_MEDIA_IMAGES", "READ_MEDIA_VIDEO", "READ_MEDIA_AUDIO", "READ_EXTERNAL_STORAGE", "MANAGE_EXTERNAL_STORAGE"), ), + SENSITIVE_NOTIFICATIONS( + key = "SENSITIVE_NOTIFICATIONS", + titleRes = R.string.perm_sensitive_notifications_title, + iconRes = R.drawable.rounded_vpn_key_24, + ), NOTIFICATION_BUBBLES( key = "NOTIFICATION_BUBBLES", titleRes = R.string.perm_bubbles_title, @@ -145,6 +151,7 @@ enum class AppPermission( DRAW_OVERLAYS -> PermissionUtils.canDrawOverlays(context) WRITE_SETTINGS -> PermissionUtils.canWriteSystemSettings(context) NOTIFICATION_POLICY -> PermissionUtils.hasNotificationPolicyAccess(context) + SENSITIVE_NOTIFICATIONS -> PermissionGrantUtil.hasSensitiveNotifications(context) ROOT, SHIZUKU -> ShellUtils.isAvailable(context) && ShellUtils.hasPermission(context) else -> true } 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 10d21f9f7..513dfcc00 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 @@ -141,6 +141,7 @@ fun initPermissionRegistry() { // Post notifications permission PermissionRegistry.register("POST_NOTIFICATIONS", R.string.feat_caffeinate_title) + PermissionRegistry.register("SENSITIVE_NOTIFICATIONS", R.string.island_otp_section) PermissionRegistry.register("POST_NOTIFICATIONS", R.string.feat_battery_notification_title) // Read phone state permission diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt index 1528607f2..b79f983d5 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt @@ -31,11 +31,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -72,14 +68,10 @@ import com.sameerasw.essentials.ui.features.display.sheets.StatusGlanceCalendarO import com.sameerasw.essentials.ui.features.display.sheets.rememberIslandShowsWhileLocked import com.sameerasw.essentials.ui.modifiers.highlight import com.sameerasw.essentials.utils.HapticUtil -import com.sameerasw.essentials.utils.PermissionGrantUtil import com.sameerasw.essentials.utils.PermissionUIHelper import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.utils.ShellUtils import com.sameerasw.essentials.viewmodels.MainViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext const val ISLAND_PLACEMENT_FEATURE_ID = "Island placement" const val ISLAND_VISUALS_FEATURE_ID = "Island visuals" @@ -902,16 +894,7 @@ fun IslandNotificationSettingsUI( val context = LocalContext.current val view = LocalView.current var showFilterAppsSheet by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - var otpReadable by remember { mutableStateOf(PermissionGrantUtil.hasSensitiveNotifications(context)) } - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) otpReadable = PermissionGrantUtil.hasSensitiveNotifications(context) - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } + var showOtpPermissions by remember { mutableStateOf(false) } var showQueueSheet by remember { mutableStateOf( viewModel.isIslandNotifQueue.value && @@ -1165,6 +1148,17 @@ fun IslandNotificationSettingsUI( } } + if (showOtpPermissions) { + PermissionsBottomSheet( + onDismissRequest = { + showOtpPermissions = false + viewModel.check(context) + }, + featureTitle = stringResource(R.string.island_otp_section), + permissions = PermissionUIHelper.getPermissionItems(listOf("SENSITIVE_NOTIFICATIONS"), context, viewModel), + ) + } + IslandSettingsSection(R.string.island_otp_section) { val otpEnabled = viewModel.isIslandOtpEnabled.value IconToggleItem( @@ -1173,31 +1167,14 @@ fun IslandNotificationSettingsUI( isChecked = otpEnabled, onCheckedChange = { checked -> HapticUtil.performVirtualKeyHaptic(view) - viewModel.setIslandOtpEnabled(checked) + if (checked && !viewModel.isSensitiveNotificationsGranted.value) { + showOtpPermissions = true + } else { + viewModel.setIslandOtpEnabled(checked) + } }, modifier = Modifier.highlight(highlightSetting == "island_otp_enabled"), ) - AnimatedVisibility( - visible = otpEnabled && !otpReadable, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - val featureName = stringResource(R.string.island_otp_section) - IconToggleItem( - iconRes = R.drawable.rounded_vpn_key_24, - title = stringResource(R.string.island_otp_grant_title), - showToggle = false, - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - scope.launch { - otpReadable = withContext(Dispatchers.IO) { - PermissionGrantUtil.grantSensitiveNotifications(context, featureName) - PermissionGrantUtil.hasSensitiveNotifications(context) - } - } - }, - ) - } AnimatedVisibility( visible = otpEnabled, enter = fadeIn() + expandVertically(), diff --git a/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt b/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt index d595d9c2e..313a8d477 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt @@ -23,6 +23,7 @@ import com.sameerasw.essentials.domain.model.AppPermission import com.sameerasw.essentials.domain.registry.PermissionRegistry import com.sameerasw.essentials.ui.core.sheets.PermissionItem import com.sameerasw.essentials.utils.PermissionUtils +import kotlinx.coroutines.launch import com.sameerasw.essentials.viewmodels.MainViewModel object PermissionUIHelper { @@ -517,6 +518,37 @@ object PermissionUIHelper { ) } + AppPermission.SENSITIVE_NOTIFICATIONS -> + PermissionItem( + iconRes = permission.iconRes, + title = permission.titleRes, + description = R.string.perm_sensitive_notifications_desc, + dependentFeatures = PermissionRegistry.getFeatures(permission), + actionLabel = if (viewModel.isSensitiveNotificationsGranted.value) R.string.perm_action_granted else R.string.perm_action_copy_adb, + action = { + val adbCommand = "adb shell appops set ${context.packageName} RECEIVE_SENSITIVE_NOTIFICATIONS allow" + val clipboard = context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("adb_command", adbCommand)) + }, + secondaryActionLabel = R.string.perm_action_check, + secondaryAction = { viewModel.check(context) }, + isGranted = viewModel.isSensitiveNotificationsGranted.value, + shizukuActionLabel = if (viewModel.isRootEnabled.value) R.string.perm_action_grant_root else R.string.perm_action_grant_shizuku, + shizukuActionEnabled = + if (viewModel.isRootEnabled.value) { + viewModel.isRootAvailable.value + } else { + ShizukuUtils.hasPermission() + }, + shizukuAction = { + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + PermissionGrantUtil.grantSensitiveNotifications(context) + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { viewModel.check(context) } + } + }, + instructions = R.string.perm_sensitive_notifications_instructions, + ) + AppPermission.NOTIFICATION_BUBBLES -> PermissionItem( iconRes = permission.iconRes, 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 98edd8749..af2c0b880 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -85,6 +85,7 @@ import com.sameerasw.essentials.utils.AppIconUtil import com.sameerasw.essentials.utils.AppUtil import com.sameerasw.essentials.utils.DeviceUtils import com.sameerasw.essentials.utils.DuoBatteryLevels +import com.sameerasw.essentials.utils.PermissionGrantUtil import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.utils.RefreshRateUtils import com.sameerasw.essentials.utils.RootUtils @@ -289,7 +290,7 @@ class MainViewModel : ViewModel() { val isIslandNotifTapToOpen = mutableStateOf(false) val isIslandCatchUpEnabled = mutableStateOf(true) val islandCatchUpTimeoutMs = mutableLongStateOf(10000L) - val isIslandOtpEnabled = mutableStateOf(true) + val isIslandOtpEnabled = mutableStateOf(false) val islandOtpTimeoutMs = mutableLongStateOf(60_000L) val isIslandShowGlow = mutableStateOf(true) val isIslandBorderOutline = mutableStateOf(false) @@ -604,6 +605,7 @@ class MainViewModel : ViewModel() { val isWriteSettingsEnabled = mutableStateOf(false) val isCalendarPermissionGranted = mutableStateOf(false) val isBubblePermissionGranted = mutableStateOf(false) + val isSensitiveNotificationsGranted = mutableStateOf(true) val isUserDictionaryEnabled = mutableStateOf(false) val userDictionaryWords = mutableStateOf>(emptyMap()) val isUserDictionarySheetVisible = mutableStateOf(false) @@ -1868,6 +1870,7 @@ class MainViewModel : ViewModel() { isAccessibilityEnabled.value = PermissionUtils.isAccessibilityServiceEnabled(context) isWriteSecureSettingsEnabled.value = PermissionUtils.canWriteSecureSettings(context) isBubblePermissionGranted.value = PermissionUtils.hasBubblePermission(context) + isSensitiveNotificationsGranted.value = PermissionGrantUtil.hasSensitiveNotifications(context) isShizukuAvailable.value = ShizukuUtils.isShizukuAvailable() isShizukuPermissionGranted.value = ShizukuUtils.hasPermission() if (cachedIsUpdateAvailable) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c6a8ee94..9f8312f3d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2982,6 +2982,9 @@ Show granted Hide granted Floating Bubbles + Read sensitive notifications + Android hides one-time codes from notification listeners. Essentials needs this access to show codes on the island. + Grant it with Shizuku or root, or run the command over ADB. Allow web previews to open in floating conversation bubbles above other apps Set to "All conversations can bubble" in app notification settings, or grant via Shizuku / Root. Frozen Apps From 90df9475b389033c1b164d2d6ac05f318b0c4557 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 10:31:00 +0530 Subject: [PATCH 12/24] fix: pause haptics and background loops when Conscious Gate is inactive or screen is off --- .../consciousgate/ConsciousGatePlugin.kt | 4 ++- .../ui/activities/ConsciousGateActivity.kt | 11 ++++++++ .../consciousgate/ConsciousGatePauseScreen.kt | 25 +++++++++++++------ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/consciousgate/ConsciousGatePlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/consciousgate/ConsciousGatePlugin.kt index 7d97b23d9..cbc930f87 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/consciousgate/ConsciousGatePlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/consciousgate/ConsciousGatePlugin.kt @@ -2,6 +2,7 @@ package com.sameerasw.essentials.island.plugins.consciousgate import com.sameerasw.essentials.island.ui.components.MarqueeText import android.content.Context +import android.os.PowerManager import android.graphics.Bitmap import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -108,7 +109,8 @@ class ConsciousGatePlugin : BaseIslandPlugin() { if (lastHapticSecond != totalSec) { lastHapticSecond = totalSec val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) - if (prefs.getBoolean("conscious_gate_feel_every_second", false)) { + val interactive = context.getSystemService(PowerManager::class.java)?.isInteractive != false + if (interactive && prefs.getBoolean("conscious_gate_feel_every_second", false)) { HapticUtil.performHapticForService(context, HapticFeedbackType.SUBTLE) } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt index 539a160ba..f41a43528 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt @@ -29,6 +29,7 @@ import com.sameerasw.essentials.R import com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService import com.sameerasw.essentials.ui.features.consciousgate.ConsciousGatePauseScreen import com.sameerasw.essentials.ui.theme.EssentialsTheme +import com.sameerasw.essentials.utils.HapticUtil class ConsciousGateActivity : AppCompatActivity() { private var packageToGate: String? = null @@ -156,6 +157,16 @@ class ConsciousGateActivity : AppCompatActivity() { finishAndTransition() } + override fun onPause() { + HapticUtil.stopHoldHaptic(this) + super.onPause() + } + + override fun onStop() { + HapticUtil.stopHoldHaptic(this) + super.onStop() + } + @Deprecated("Deprecated in Java") override fun onBackPressed() { notifyClosedAndFinish() diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt index 7a7c80302..56bb94ef6 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt @@ -59,6 +59,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.sameerasw.essentials.R import com.sameerasw.essentials.utils.AppUtil import com.sameerasw.essentials.utils.HapticUtil @@ -102,11 +105,14 @@ fun ConsciousGatePauseScreen( var isHolding by remember { mutableStateOf(false) } val view = LocalView.current + val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(isHolding) { - while (isActive) { - delay(1000L) - if (!isHolding && !isActionInvoked) { - HapticUtil.performLightHaptic(view) + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (isActive) { + delay(1000L) + if (!isHolding && !isActionInvoked) { + HapticUtil.performLightHaptic(view) + } } } } @@ -301,10 +307,13 @@ internal fun HoldToContinueButton( } } - tryAwaitRelease() - onHoldingChange(false) - animJob.cancel() - HapticUtil.stopHoldHaptic(context) + try { + tryAwaitRelease() + } finally { + onHoldingChange(false) + animJob.cancel() + HapticUtil.stopHoldHaptic(context) + } if (completed || holdProgress.value >= 0.99f) { HapticUtil.performCustomHaptic(view, 1.0f) From 5a47addfffcd1f3c9a05f26346de266cdc966852 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 10:33:18 +0530 Subject: [PATCH 13/24] feat: prevent notification reposting after reply and reset input state --- .../notifications/NotificationExpanded.kt | 2 ++ .../notifications/NotificationsPlugin.kt | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationExpanded.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationExpanded.kt index c4ad47e3d..8a216a644 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationExpanded.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationExpanded.kt @@ -127,6 +127,7 @@ fun NotificationExpanded( accent = glowColor, onSend = { text -> scope.setTextInput(false) + replyAction = null onReply(replying, text) }, onCancel = { @@ -181,6 +182,7 @@ private fun ReplyField( if (value.isEmpty()) return IslandHaptics.button(context) keyboard?.hide() + text = "" onSend(value) } Row( diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt index cd9f665b5..b6dbb5d75 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt @@ -155,6 +155,7 @@ class NotificationsPlugin : BaseIslandPlugin() { private val timeoutRunnable = Runnable { onTimeout() } private val catchUpRunnable = Runnable { clearAll() } + private val replied = HashMap() private var copiedKey: String? = null private var postedWhileCopied = false private val copiedRunnable = Runnable { finishCopy() } @@ -234,6 +235,9 @@ class NotificationsPlugin : BaseIslandPlugin() { render() return } + val now = System.currentTimeMillis() + replied.values.removeAll { now - it > REPLY_REPOST_WINDOW_MS } + if (replied.containsKey(alert.key)) return if (copiedKey != null) { alerts.addLast(alert) while (alerts.size > MAX_QUEUE) alerts.removeFirst() @@ -720,13 +724,21 @@ class NotificationsPlugin : BaseIslandPlugin() { val sent = canCarryText && NotificationListener.instance?.performNotificationAction(action, text) == true if (sent) { Toast.makeText(context, R.string.island_reply_sent, Toast.LENGTH_SHORT).show() - if (currentAlert()?.key == alert.key) popCurrent(reExpand = false) + dismissAfterReply(alert) } else { - popCurrent(reExpand = false) + dismissAfterReply(alert) context.performGlobalAction(AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS) } } + private fun dismissAfterReply(alert: ActiveNotificationAlert) { + val c = ctx ?: return + replied[alert.key] = System.currentTimeMillis() + val wasFocused = c.focusedKey() == ITEM_KEY + onRemoved(alert.key) + if (wasFocused) c.request(PluginRequest.Collapse(ITEM_KEY)) + } + private fun runAction(alert: ActiveNotificationAlert, action: NotificationActionItem) { HapticUtil.performStrongTickHaptic(context) if (action.isQuickReply) { @@ -742,6 +754,7 @@ class NotificationsPlugin : BaseIslandPlugin() { const val ITEM_KEY = "notifications" private const val MAX_QUEUE = 20 private const val COPIED_MS = 1_500L + private const val REPLY_REPOST_WINDOW_MS = 10_000L private val GENERIC_TITLES = setOf( "you", "whatsapp", "messages", "telegram", "gmail", "instagram", "slack", "discord", "essentials", From 600dd5055bdfeae18fccb2f8cc57c4960de19bfd Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 10:43:50 +0530 Subject: [PATCH 14/24] fix: exclude progress-based notifications from chronometer tracking --- .../essentials/utils/chronometer/ChronometerRepository.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/utils/chronometer/ChronometerRepository.kt b/app/src/main/java/com/sameerasw/essentials/utils/chronometer/ChronometerRepository.kt index 5d5f1af9d..4a9c175b4 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/chronometer/ChronometerRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/chronometer/ChronometerRepository.kt @@ -96,15 +96,19 @@ object ChronometerRepository { if (n.extras.containsKey(Notification.EXTRA_MEDIA_SESSION)) return false val chronometer = n.extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER) || timeMetric(n.extras) != null - return (chronometer && sbn.isOngoing) || synchronized(this) { tracked.containsKey(sbn.key) } + return (chronometer && sbn.isOngoing && !hasProgress(n.extras)) || synchronized(this) { tracked.containsKey(sbn.key) } } + private fun hasProgress(extras: Bundle): Boolean = + extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0) > 0 || + extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE, false) + @Synchronized fun onPosted(context: Context, sbn: StatusBarNotification) { val n = sbn.notification val extras = n.extras val previous = tracked[sbn.key] - if (!sbn.isOngoing) { + if (!sbn.isOngoing || hasProgress(extras)) { remove(sbn.key) return } From 77cee9d7991106037b8723f3f1fb94b434f6164d Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 10:47:22 +0530 Subject: [PATCH 15/24] feat: keep island notificaiotns optionally --- .../essentials/island/model/IslandPlugin.kt | 1 + .../notifications/NotificationsPlugin.kt | 21 ++++++++++++++++++- .../island/service/IslandCoordinator.kt | 9 +++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/model/IslandPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandPlugin.kt index 3b4ceaa20..af1922418 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/model/IslandPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/model/IslandPlugin.kt @@ -27,6 +27,7 @@ interface IslandPlugin { fun stop() fun refresh() {} fun onScreenStateChanged() = refresh() + fun onSuppressionChanged(suppressed: Boolean) {} fun onUserInteraction(focusedKey: String?) {} fun onFocusChanged(stage: IslandStage, focusedKey: String?) {} } diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt index b6dbb5d75..d57c86f2c 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt @@ -153,9 +153,12 @@ class NotificationsPlugin : BaseIslandPlugin() { private var autoExpanded = false private val timeoutRunnable = Runnable { onTimeout() } - private val catchUpRunnable = Runnable { clearAll() } + private val catchUpRunnable = Runnable { + if (ctx?.isContentSuppressed?.invoke() == true) catchUpPaused = true else clearAll() + } private val replied = HashMap() + private var catchUpPaused = false private var copiedKey: String? = null private var postedWhileCopied = false private val copiedRunnable = Runnable { finishCopy() } @@ -192,6 +195,21 @@ class NotificationsPlugin : BaseIslandPlugin() { if (!settings.isIslandShowNotificationsEnabled()) clearAll() else render() } + override fun onSuppressionChanged(suppressed: Boolean) { + val c = ctx ?: return + if (suppressed) { + if (catchingUp) { + c.mainHandler.removeCallbacks(catchUpRunnable) + catchUpPaused = true + } + } else if (catchUpPaused) { + catchUpPaused = false + if (alerts.isNotEmpty() && catchingUp && !settings.isIslandCatchUpInfinite()) { + c.mainHandler.postDelayed(catchUpRunnable, settings.getIslandCatchUpTimeoutMs()) + } + } + } + override fun onUserInteraction(focusedKey: String?) { if (alerts.isEmpty() || pendingPopUp) return if (focusedKey == ITEM_KEY) autoExpanded = false @@ -363,6 +381,7 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun clearAll() { catchingUp = false + catchUpPaused = false copiedKey = null postedWhileCopied = false cancelTimers() 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 f6bf6197c..20dd3d9d2 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 @@ -722,8 +722,15 @@ class IslandCoordinator( applyPreviewRing() } + private var lastContentSuppressed = false + private fun applySuppression() { - controller.setSuppressed(isContentSuppressed || revealing || hiding) + val contentSuppressed = isContentSuppressed + if (contentSuppressed != lastContentSuppressed) { + lastContentSuppressed = contentSuppressed + plugins.forEach { it.onSuppressionChanged(contentSuppressed) } + } + controller.setSuppressed(contentSuppressed || revealing || hiding) windowHost.setInteractionsDisabled( isFullscreenApp && settings.isIslandKeepInFullscreenEnabled() && settings.isIslandDisableInteractionsInFullscreenEnabled(), ) From 29615f01052f2b2620f8246d7bf9be03f45b328e Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 10:50:52 +0530 Subject: [PATCH 16/24] refactor: restructure call plugin button generation and 3rd party app calls --- .../island/plugins/call/CallPlugin.kt | 176 +++++++++--------- .../utils/call/CallNotificationParser.kt | 2 + .../utils/call/CallStateRepository.kt | 2 + 3 files changed, 90 insertions(+), 90 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/call/CallPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/call/CallPlugin.kt index 9b8610c90..3daeeef5f 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/call/CallPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/call/CallPlugin.kt @@ -4,6 +4,7 @@ import com.sameerasw.essentials.island.plugins.sendPendingIntent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp +import android.telecom.TelecomManager import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.island.model.CompactCell @@ -178,104 +179,99 @@ class CallPlugin : BaseIslandPlugin() { } private fun buttonsFor(snap: CallSnapshot, ringing: Boolean): List { - val telephony = snap.fromTelephony - val canAnswer = snap.answerIntent != null || telephony - val canEnd = snap.endIntent != null || telephony - val buttons = mutableListOf() - fun end(label: Int) { - if (!canEnd) return - buttons += CallButtonSpec( - key = "end", - icon = R.drawable.rounded_call_end_24, - label = context.getString(label), - style = CallButtonStyle.End, - onClick = { - guarded { - if (telephony) CallControlUtil.endCall(context) - else if (!sendPendingIntent(context, snap.endIntent)) CallControlUtil.endCall(context) - } - null - }, - ) - } - fun answer() { - if (!canAnswer) return - buttons += CallButtonSpec( - key = "answer", - icon = R.drawable.rounded_call_24, - label = context.getString(R.string.island_call_answer), - style = CallButtonStyle.Answer, - onClick = { - guarded { - if (telephony) CallControlUtil.acceptCall(context) - else if (!sendPendingIntent(context, snap.answerIntent)) CallControlUtil.acceptCall(context) - } - null - }, - ) - } - if (ringing) { - end(R.string.island_call_decline) - answer() - } else { - val own = snap.extraActions - val mute = own.firstOrNull { it.role == CallActionRole.Mute } - val speaker = own.firstOrNull { it.role == CallActionRole.Speaker } - if (telephony) { - buttons += CallButtonSpec( - key = "mute", - icon = R.drawable.rounded_mic_24, - onIcon = R.drawable.rounded_mic_off_24, - label = context.getString(R.string.island_call_mute), - initialOn = guarded(false) { CallControlUtil.isMuted(context) }, - onClick = { guarded(null) { CallControlUtil.toggleMute(context) } }, - ) - buttons += CallButtonSpec( - key = "speaker", - icon = R.drawable.rounded_volume_up_24, - label = context.getString(R.string.island_call_speaker), - initialOn = guarded(false) { CallControlUtil.isSpeakerOn(context) }, - onClick = { guarded(null) { CallControlUtil.toggleSpeaker(context) } }, - ) - } else { - if (mute != null) { - buttons += ownButton(mute, R.drawable.rounded_mic_24, on = mute.title.lowercase().contains("unmute"), onIcon = R.drawable.rounded_mic_off_24) - } - if (speaker != null) { - buttons += ownButton(speaker, R.drawable.rounded_volume_up_24, on = false) - } + val buttons = if (usesOwnControls(snap)) notificationButtons(snap, ringing) else telephonyButtons(snap, ringing) + if (buttons.isNotEmpty()) return buttons + return listOf(openButton(snap)) + } + + private fun usesOwnControls(snap: CallSnapshot): Boolean { + val hasOwn = snap.allActions.isNotEmpty() || snap.answerIntent != null || snap.endIntent != null + if (!hasOwn) return false + if (!snap.fromTelephony) return true + val dialer = guarded(null) { context.getSystemService(TelecomManager::class.java)?.defaultDialerPackage } + return snap.packageName != null && snap.packageName != dialer + } + + private fun telephonyButtons(snap: CallSnapshot, ringing: Boolean): List { + val end = CallButtonSpec( + key = "end", + icon = R.drawable.rounded_call_end_24, + label = context.getString(if (ringing) R.string.island_call_decline else R.string.island_call_end), + style = CallButtonStyle.End, + onClick = { + guarded { CallControlUtil.endCall(context) } + null + }, + ) + if (!ringing) return listOf(openButton(snap), end) + val answer = CallButtonSpec( + key = "answer", + icon = R.drawable.rounded_call_24, + label = context.getString(R.string.island_call_answer), + style = CallButtonStyle.Answer, + onClick = { + guarded { CallControlUtil.acceptCall(context) } + null + }, + ) + return listOf(end, answer) + } + + private fun notificationButtons(snap: CallSnapshot, ringing: Boolean): List { + val actions = snap.allActions.ifEmpty { legacyActions(snap, ringing) } + return actions.take(MAX_NOTIFICATION_BUTTONS).map { action -> + val style = when (action.role) { + CallActionRole.Answer -> CallButtonStyle.Answer + CallActionRole.Decline, CallActionRole.HangUp -> CallButtonStyle.End + else -> CallButtonStyle.Neutral } - own.filter { it.role != CallActionRole.Mute && it.role != CallActionRole.Speaker } - .take(MAX_EXTRA_BUTTONS) - .forEach { action -> - val icon = when (action.role) { - CallActionRole.Hold -> R.drawable.rounded_pause_24 - CallActionRole.Video -> R.drawable.rounded_videocam_24 - else -> null - } - buttons += ownButton(action, icon, on = false) - } - end(R.string.island_call_end) + val icon = when (action.role) { + CallActionRole.Answer -> R.drawable.rounded_call_24 + CallActionRole.Decline, CallActionRole.HangUp -> R.drawable.rounded_call_end_24 + CallActionRole.Mute -> R.drawable.rounded_mic_24 + CallActionRole.Speaker -> R.drawable.rounded_volume_up_24 + CallActionRole.Hold -> R.drawable.rounded_pause_24 + CallActionRole.Video -> R.drawable.rounded_videocam_24 + CallActionRole.Other -> null + } + val on = action.role == CallActionRole.Mute && action.title.lowercase().contains("unmute") + ownButton(action, icon, on = on, onIcon = if (action.role == CallActionRole.Mute) R.drawable.rounded_mic_off_24 else null, style = style) } - if (buttons.isEmpty()) { - buttons += CallButtonSpec( - key = "open", - icon = R.drawable.rounded_open_in_new_24, - label = context.getString(R.string.island_call_open), - onClick = { - guarded { if (!sendPendingIntent(context, snap.contentIntent)) CallControlUtil.showInCallScreen(context) } - null - }, - ) + } + + private fun legacyActions(snap: CallSnapshot, ringing: Boolean): List = buildList { + val answer = snap.answerIntent + val end = snap.endIntent + if (ringing) { + if (end != null) add(CallNotificationAction(CallActionRole.Decline, context.getString(R.string.island_call_decline), end)) + if (answer != null) add(CallNotificationAction(CallActionRole.Answer, context.getString(R.string.island_call_answer), answer)) + } else if (end != null) { + add(CallNotificationAction(CallActionRole.HangUp, context.getString(R.string.island_call_end), end)) } - return buttons } - private fun ownButton(action: CallNotificationAction, icon: Int?, on: Boolean, onIcon: Int? = null) = CallButtonSpec( + private fun openButton(snap: CallSnapshot) = CallButtonSpec( + key = "open", + icon = R.drawable.rounded_open_in_new_24, + label = context.getString(R.string.island_call_open), + onClick = { + guarded { if (!sendPendingIntent(context, snap.contentIntent)) CallControlUtil.showInCallScreen(context) } + null + }, + ) + + private fun ownButton( + action: CallNotificationAction, + icon: Int?, + on: Boolean, + onIcon: Int? = null, + style: CallButtonStyle = CallButtonStyle.Neutral, + ) = CallButtonSpec( key = "own:${action.role}:${action.title}", icon = icon, onIcon = onIcon, label = action.title, + style = style, initialOn = on, onClick = { guarded { sendPendingIntent(context, action.intent) } @@ -307,7 +303,7 @@ class CallPlugin : BaseIslandPlugin() { companion object { const val ITEM_KEY = "call" - private const val MAX_EXTRA_BUTTONS = 2 + private const val MAX_NOTIFICATION_BUTTONS = 3 } } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/call/CallNotificationParser.kt b/app/src/main/java/com/sameerasw/essentials/utils/call/CallNotificationParser.kt index 0b6a7ccfa..6ff391c89 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/call/CallNotificationParser.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/call/CallNotificationParser.kt @@ -33,6 +33,7 @@ data class NotificationCall( val contentIntent: PendingIntent?, val postedAt: Long, val extraActions: List = emptyList(), + val allActions: List = emptyList(), ) object CallNotificationParser { @@ -134,6 +135,7 @@ object CallNotificationParser { contentIntent = n.contentIntent, postedAt = sbn.postTime, extraActions = extraActions, + allActions = classified.filter { it.title.isNotBlank() }, ) } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/call/CallStateRepository.kt b/app/src/main/java/com/sameerasw/essentials/utils/call/CallStateRepository.kt index 817bc7e6c..673976244 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/call/CallStateRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/call/CallStateRepository.kt @@ -27,6 +27,7 @@ data class CallSnapshot( val contentIntent: PendingIntent? = null, val fromTelephony: Boolean = false, val extraActions: List = emptyList(), + val allActions: List = emptyList(), ) @@ -140,6 +141,7 @@ object CallStateRepository { contentIntent = call.contentIntent, fromTelephony = phone != null, extraActions = call.extraActions, + allActions = call.allActions, ) } From ccd5a121233fb20126fc61954aa51c02778eb499 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 10:53:13 +0530 Subject: [PATCH 17/24] fix: add connection settle delays and debouncing to Bluetooth and Network plugins --- .../island/plugins/devices/DevicesPlugin.kt | 43 ++++++++++++++++++- .../island/plugins/network/NetworkPlugin.kt | 36 ++++++++++++++-- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/devices/DevicesPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/devices/DevicesPlugin.kt index 57015da85..4e4682beb 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/devices/DevicesPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/devices/DevicesPlugin.kt @@ -58,6 +58,8 @@ class DevicesPlugin : BaseIslandPlugin() { private var batteryDevice: BatteryDevice? = null private var registered = false private val pendingDisconnects = mutableMapOf() + private val pendingConnects = mutableMapOf() + private val announcedConnected = mutableSetOf() private val clearEvent = Runnable { event = null render() @@ -73,14 +75,28 @@ class DevicesPlugin : BaseIslandPlugin() { if (pending != null) { ctx?.mainHandler?.removeCallbacks(pending) } else { - announce(device, connected = true) + pendingConnects.remove(device.address)?.let { ctx?.mainHandler?.removeCallbacks(it) } + val confirm = Runnable { + pendingConnects.remove(device.address) + if (isConnected(device)) { + announcedConnected += device.address + announce(device, connected = true) + } + } + pendingConnects[device.address] = confirm + ctx?.mainHandler?.postDelayed(confirm, CONNECT_SETTLE_MS) } } BluetoothDevice.ACTION_ACL_DISCONNECTED -> { + val unconfirmed = pendingConnects.remove(device.address) + if (unconfirmed != null) { + ctx?.mainHandler?.removeCallbacks(unconfirmed) + return + } pendingDisconnects.remove(device.address)?.let { ctx?.mainHandler?.removeCallbacks(it) } val confirm = Runnable { pendingDisconnects.remove(device.address) - announce(device, connected = false) + if (announcedConnected.remove(device.address)) announce(device, connected = false) } pendingDisconnects[device.address] = confirm ctx?.mainHandler?.postDelayed(confirm, DISCONNECT_SETTLE_MS) @@ -105,6 +121,7 @@ class DevicesPlugin : BaseIslandPlugin() { } context.registerReceiver(receiver, filter) registered = true + seedConnected() } catch (_: Exception) { } } @@ -113,6 +130,9 @@ class DevicesPlugin : BaseIslandPlugin() { ctx?.mainHandler?.removeCallbacks(clearEvent) pendingDisconnects.values.forEach { ctx?.mainHandler?.removeCallbacks(it) } pendingDisconnects.clear() + pendingConnects.values.forEach { ctx?.mainHandler?.removeCallbacks(it) } + pendingConnects.clear() + announcedConnected.clear() event = null batteryDevice = null if (registered) { @@ -126,9 +146,27 @@ class DevicesPlugin : BaseIslandPlugin() { override fun refresh() { ctx ?: return + seedConnected() refreshBattery() } + @SuppressLint("MissingPermission") + private fun seedConnected() { + if (!hasPermission()) return + val bonded = try { + (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter?.bondedDevices + } catch (_: SecurityException) { + null + }.orEmpty() + bonded.filter { isConnected(it) }.forEach { announcedConnected += it.address } + } + + private fun isConnected(device: BluetoothDevice): Boolean = try { + device.javaClass.getMethod("isConnected").invoke(device) as Boolean + } catch (_: Exception) { + true + } + @SuppressLint("MissingPermission") private fun refreshBattery() { if (ctx == null) return @@ -315,6 +353,7 @@ class DevicesPlugin : BaseIslandPlugin() { const val BATTERY_KEY = "devices.battery" private const val BATTERY_SETTLE_MS = 1500L private const val DISCONNECT_SETTLE_MS = 2000L + private const val CONNECT_SETTLE_MS = 3000L private const val ACTION_BATTERY_LEVEL_CHANGED = "android.bluetooth.device.action.BATTERY_LEVEL_CHANGED" private const val EXTRA_BATTERY_LEVEL = "android.bluetooth.device.extra.BATTERY_LEVEL" } diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/network/NetworkPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/network/NetworkPlugin.kt index 1ed2479c0..2404b7356 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/network/NetworkPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/network/NetworkPlugin.kt @@ -43,6 +43,8 @@ class NetworkPlugin : BaseIslandPlugin() { private var ssid: String? = null private var airplane = false private var event: WifiEvent? = null + private var pendingLost: Runnable? = null + private var lostSsid: String? = null private val clearEvent = Runnable { event = null render() @@ -81,6 +83,9 @@ class NetworkPlugin : BaseIslandPlugin() { override fun onStop() { ctx?.mainHandler?.removeCallbacks(clearEvent) + pendingLost?.let { ctx?.mainHandler?.removeCallbacks(it) } + pendingLost = null + lostSsid = null try { connectivity.unregisterNetworkCallback(callback) } catch (_: Exception) { @@ -103,16 +108,38 @@ class NetworkPlugin : BaseIslandPlugin() { return } val name = ssidFrom(caps) ?: WifiUtil.getCurrentSsid(context) + val pending = pendingLost + if (pending != null) { + ctx?.mainHandler?.removeCallbacks(pending) + pendingLost = null + val previous = lostSsid + lostSsid = null + if (previous == null || name == null || previous == name) { + wifiNetwork = network + ssid = name ?: previous + return + } + announce(connected = false) + } + val sameNetwork = ssid != null && name == ssid wifiNetwork = network ssid = name - announce(connected = true) + if (!sameNetwork) announce(connected = true) } private fun onWifiLost(network: Network) { - if (ctx == null || network != wifiNetwork) return + val c = ctx ?: return + if (network != wifiNetwork) return wifiNetwork = null - announce(connected = false) - ssid = null + lostSsid = ssid + val confirm = Runnable { + pendingLost = null + lostSsid = null + announce(connected = false) + ssid = null + } + pendingLost = confirm + c.mainHandler.postDelayed(confirm, WIFI_LOST_SETTLE_MS) } private fun ssidFrom(caps: NetworkCapabilities): String? { @@ -180,5 +207,6 @@ class NetworkPlugin : BaseIslandPlugin() { companion object { const val WIFI_KEY = "network.wifi" const val AIRPLANE_KEY = "network.airplane" + private const val WIFI_LOST_SETTLE_MS = 4000L } } From 1ab1d8751c432bef65444ef67e4a99d4a339f584 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 11:05:07 +0530 Subject: [PATCH 18/24] feat: add secondary bubble setting to island features --- .../essentials/data/repository/SettingsRepository.kt | 5 +++++ .../essentials/domain/registry/FeatureRegistry.kt | 5 +++++ .../essentials/island/service/IslandCoordinator.kt | 2 ++ .../essentials/island/state/IslandController.kt | 3 ++- .../ui/features/display/IslandSubSettingsUI.kt | 11 +++++++++++ .../sameerasw/essentials/viewmodels/MainViewModel.kt | 7 +++++++ app/src/main/res/values/strings.xml | 1 + 7 files changed, 33 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 422a8c13b..0550e28d7 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -591,6 +591,7 @@ class SettingsRepository( const val KEY_ISLAND_LANDSCAPE_TOP_SPACING = "island_landscape_top_spacing" const val KEY_ISLAND_BOND_EDGE = "island_bond_edge" const val KEY_ISLAND_BALANCE_COMPACT = "island_balance_compact" + const val KEY_ISLAND_SECONDARY_BUBBLE = "island_secondary_bubble" const val KEY_ISLAND_MAX_ITEMS = "island_max_items" const val KEY_ISLAND_ALWAYS_GESTURES = "island_always_gestures" const val KEY_ISLAND_DISMISS_ON_OUTSIDE = "island_dismiss_on_outside" @@ -4300,6 +4301,10 @@ class SettingsRepository( fun setIslandBalanceCompactEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_BALANCE_COMPACT, enabled) + fun isIslandSecondaryBubbleEnabled(): Boolean = getBoolean(KEY_ISLAND_SECONDARY_BUBBLE, true) + + fun setIslandSecondaryBubbleEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_SECONDARY_BUBBLE, enabled) + fun getIslandLandscapeTopSpacing(): Float = getFloat(KEY_ISLAND_LANDSCAPE_TOP_SPACING, 0f) fun setIslandLandscapeTopSpacing(value: Float) = putFloat(KEY_ISLAND_LANDSCAPE_TOP_SPACING, value) 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 8775d2298..efb2a3a74 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 @@ -1008,6 +1008,11 @@ object FeatureRegistry { R.string.island_line_peek_desc, "island_line_stage_enabled", ), + SearchSetting( + R.string.island_secondary_bubble_title, + R.string.island_section_behavior, + "island_secondary_bubble", + ), SearchSetting( R.string.island_balance_compact_title, R.string.island_section_behavior, 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 20dd3d9d2..b805d557e 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 @@ -717,6 +717,7 @@ class IslandCoordinator( controller.lineStageEnabled = settings.isIslandLineStageEnabled() controller.maxCells = settings.getIslandMaxItems() * 2 controller.balanceCompact = settings.isIslandBalanceCompactEnabled() + controller.secondaryBubbleEnabled = settings.isIslandSecondaryBubbleEnabled() controller.relayout() controller.expandedTimeoutMs = settings.getIslandExpandedTimeoutMs() applyPreviewRing() @@ -844,6 +845,7 @@ class IslandCoordinator( SettingsRepository.KEY_ISLAND_LANDSCAPE_TOP_SPACING, SettingsRepository.KEY_ISLAND_BOND_EDGE, SettingsRepository.KEY_ISLAND_BALANCE_COMPACT, + SettingsRepository.KEY_ISLAND_SECONDARY_BUBBLE, SettingsRepository.KEY_ISLAND_MAX_ITEMS, ) } diff --git a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt index 4b7b46ac5..16a22a7ce 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt @@ -37,6 +37,7 @@ class IslandController( var maxCells: Int = CompactLayoutEngine.MAX_CELLS var balanceCompact: Boolean = true + var secondaryBubbleEnabled: Boolean = true private var feedbackActive = false fun setFeedbackActive(active: Boolean) { @@ -275,7 +276,7 @@ class IslandController( if (peekKey != null && items[peekKey]?.line == null) cancelPeek() val bubbleOwner = items.values - .filter { it.sideBubble != null } + .filter { secondaryBubbleEnabled && it.sideBubble != null } .sortedBy { it.sideBubble!!.priority } .firstOrNull { candidate -> !candidate.sideBubble!!.whenOccupiedOnly || items.values.any { diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt index b79f983d5..ad259bf9e 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt @@ -605,6 +605,17 @@ fun IslandBehaviorSettingsUI( modifier = Modifier.highlight(highlightSetting == "island_line_stage_enabled"), ) + IconToggleItem( + iconRes = R.drawable.rounded_notifications_unread_24, + title = stringResource(R.string.island_secondary_bubble_title), + isChecked = viewModel.isIslandSecondaryBubble.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setIslandSecondaryBubble(checked) + }, + modifier = Modifier.highlight(highlightSetting == "island_secondary_bubble"), + ) + IconToggleItem( iconRes = R.drawable.rounded_format_align_center_24, title = stringResource(R.string.island_balance_compact_title), 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 af2c0b880..aa649b3c2 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -235,6 +235,7 @@ class MainViewModel : ViewModel() { val isIslandDisableInteractionsInFullscreen = mutableStateOf(false) val isIslandBondEdge = mutableStateOf(false) val isIslandBalanceCompact = mutableStateOf(true) + val isIslandSecondaryBubble = mutableStateOf(true) val islandMaxItems = mutableStateOf(2) val isIslandAlwaysGestures = mutableStateOf(false) val islandLandscapeTopSpacing = mutableFloatStateOf(0f) @@ -2476,6 +2477,7 @@ class MainViewModel : ViewModel() { isIslandDisableInteractionsInFullscreen.value = settingsRepository.isIslandDisableInteractionsInFullscreenEnabled() isIslandBondEdge.value = settingsRepository.isIslandBondEdgeEnabled() isIslandBalanceCompact.value = settingsRepository.isIslandBalanceCompactEnabled() + isIslandSecondaryBubble.value = settingsRepository.isIslandSecondaryBubbleEnabled() islandMaxItems.value = settingsRepository.getIslandMaxItems() isIslandAlwaysGestures.value = settingsRepository.isIslandAlwaysGesturesEnabled() islandLandscapeTopSpacing.floatValue = settingsRepository.getIslandLandscapeTopSpacing() @@ -5865,6 +5867,11 @@ class MainViewModel : ViewModel() { settingsRepository.setIslandMaxItems(value) } + fun setIslandSecondaryBubble(enabled: Boolean) { + isIslandSecondaryBubble.value = enabled + settingsRepository.setIslandSecondaryBubbleEnabled(enabled) + } + fun setIslandBalanceCompact(enabled: Boolean) { isIslandBalanceCompact.value = enabled settingsRepository.setIslandBalanceCompactEnabled(enabled) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f8312f3d..b07b6339d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3149,6 +3149,7 @@ Keep on landscape Landscape top spacing Bond with notch when inactive + Secondary bubble Visually balance compact island Max items to show Always available gestures From 8c3463295f58e192329393e8da251e774bbe8c71 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 11:08:00 +0530 Subject: [PATCH 19/24] feat: bump version to 20.0 and update watchface promotion to public release --- app/build.gradle.kts | 6 +- .../EssentialsWatchfacePromoContent.kt | 140 +----------------- .../ui/components/WhatsNewCustomContent.kt | 2 - .../sheets/WatchInstallHelpBottomSheet.kt | 2 +- app/src/main/res/values-vi/strings.xml | 6 +- app/src/main/res/values/strings.xml | 10 +- 6 files changed, 8 insertions(+), 158 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db5470926..ccd58568f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -115,10 +115,10 @@ android { applicationId = "com.sameerasw.essentials" minSdk = 26 targetSdk = 37 - versionCode = 68 - versionName = "19.1-beta.3" + versionCode = 69 + versionName = "20.0" - val whatsNewCounter = 6 + val whatsNewCounter = 7 buildConfigField("int", "WHATS_NEW_COUNTER", whatsNewCounter.toString()) buildConfigField("int", "REQUIRED_WEAR_VERSION_CODE", "7") diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/EssentialsWatchfacePromoContent.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/EssentialsWatchfacePromoContent.kt index 24415d94a..b5039255d 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/EssentialsWatchfacePromoContent.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/EssentialsWatchfacePromoContent.kt @@ -4,157 +4,20 @@ * * Feature Module: UI Components * File: EssentialsWatchfacePromoContent.kt - * Description: Reusable modular component showcasing the Essentials Watchface app promotion and testing setup steps. + * Description: Reusable modular component showcasing the Essentials Watchface app promotion. */ package com.sameerasw.essentials.ui.components -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -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.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel 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.utils.HapticUtil import com.sameerasw.essentials.viewmodels.WatchViewModel -@Composable -fun EssentialsWatchfacePromoContent( - modifier: Modifier = Modifier, - watchViewModel: WatchViewModel = viewModel(), -) { - val context = LocalContext.current - val uriHandler = LocalUriHandler.current - val view = LocalView.current - - val googleGroupUrl = "https://groups.google.com/u/1/g/madebysameerasw" - val betaTestingUrl = "https://play.google.com/apps/testing/com.sameerasw.essentials.watchface" - - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - RoundedCardContainer( - spacing = 2.dp, - cornerRadius = 24.dp, - ) { - // Step 1: Join Google Group - IconToggleItem( - iconRes = R.drawable.rounded_person_24, - title = stringResource(R.string.watchface_promo_step1_title), - description = stringResource(R.string.watchface_promo_step1_desc), - showToggle = false, - onClick = { - HapticUtil.performUIHaptic(view) - uriHandler.openUri(googleGroupUrl) - }, - trailingContent = { - Button( - onClick = { - HapticUtil.performUIHaptic(view) - uriHandler.openUri(googleGroupUrl) - }, - shape = MaterialTheme.shapes.extraLarge, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - ) { - Text( - text = stringResource(R.string.watchface_promo_step1_action), - style = MaterialTheme.typography.labelMedium, - ) - } - }, - ) - - // Step 2: Join Beta Program - IconToggleItem( - iconRes = R.drawable.rounded_science_24, - title = stringResource(R.string.watchface_promo_step2_title), - description = stringResource(R.string.watchface_promo_step2_desc), - showToggle = false, - onClick = { - HapticUtil.performUIHaptic(view) - uriHandler.openUri(betaTestingUrl) - }, - trailingContent = { - Button( - onClick = { - HapticUtil.performUIHaptic(view) - uriHandler.openUri(betaTestingUrl) - }, - shape = MaterialTheme.shapes.extraLarge, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - ) { - Text( - text = stringResource(R.string.watchface_promo_step2_action), - style = MaterialTheme.typography.labelMedium, - ) - } - }, - ) - - // Step 3: Install on Watch - IconToggleItem( - iconRes = R.drawable.rounded_watch_24, - title = stringResource(R.string.watchface_promo_step3_title), - description = stringResource(R.string.watchface_promo_step3_desc), - showToggle = false, - onClick = { - HapticUtil.performUIHaptic(view) - watchViewModel.openWatchfacePlayStoreOnWatch(context) - }, - trailingContent = { - Button( - onClick = { - HapticUtil.performUIHaptic(view) - watchViewModel.openWatchfacePlayStoreOnWatch(context) - }, - shape = MaterialTheme.shapes.extraLarge, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ), - ) { - Icon( - painter = painterResource(id = R.drawable.rounded_open_in_new_24), - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - androidx.compose.foundation.layout.Spacer(modifier = Modifier.size(6.dp)) - Text( - text = stringResource(R.string.watchface_promo_step3_action), - style = MaterialTheme.typography.labelMedium, - ) - } - }, - ) - } - } -} - -// Play Store version, to replace the internal testing steps above once the watchface is approved: -// (the step 3 title string needs its "3. " prefix dropped again) -/* @Composable fun EssentialsWatchfacePromoContent( modifier: Modifier = Modifier, @@ -172,4 +35,3 @@ fun EssentialsWatchfacePromoContent( modifier = modifier.fillMaxWidth(), ) } -*/ diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt index a7bc35307..fc1786431 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt @@ -118,7 +118,6 @@ fun WhatsNewCustomContent( ) } - /* WhatsNewSectionCard( title = stringResource(R.string.whats_new_watchface_title), summary = stringResource(R.string.whats_new_watchface_summary), @@ -133,7 +132,6 @@ fun WhatsNewCustomContent( ) EssentialsWatchfacePromoContent() } - */ WhatsNewSectionCard( title = stringResource(R.string.setting_hidden_debugging_title), diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/watch/sheets/WatchInstallHelpBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/watch/sheets/WatchInstallHelpBottomSheet.kt index e9690e8c3..75db85eed 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/watch/sheets/WatchInstallHelpBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/watch/sheets/WatchInstallHelpBottomSheet.kt @@ -183,7 +183,7 @@ fun WatchInstallHelpBottomSheet( var isWatchfaceSectionExpanded by remember { mutableStateOf(false) } CategoryExpandableSection( title = stringResource(R.string.watch_watchface_title), - itemCount = 3, + itemCount = 1, isExpanded = isWatchfaceSectionExpanded, onToggleExpand = { isWatchfaceSectionExpanded = !isWatchfaceSectionExpanded }, ) { diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 24a49f119..f40ec053d 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1938,11 +1938,7 @@ Cần có quyền truy cập trợ năng để hiển thị lớp phủ và cầ Tạm dừng trước khi mở các ứng dụng đã chọn Không hỗ trợ Cũ - 1. Tham gia nhóm Google - Hãy tham gia nhóm thử nghiệm bằng tài khoản Gmail được liên kết với đồng hồ của bạn - 2. Tham gia Chương trình Beta - Đăng ký tham gia chương trình thử nghiệm nội bộ trên Google Play - 3. Cài đặt trên đồng hồ + Cài đặt trên đồng hồ Mở trực tiếp mục Play Store trên đồng hồ của bạn để cài đặt Vị trí Hình ảnh diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b07b6339d..d1d52149c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3495,17 +3495,11 @@ Expand settings Collapse status bar - + New: Essentials Watchface Install the Essentials watchface on your Wear OS watch straight from Google Play. Now on Google Play for Wear OS - 1. Join Google Group - Join the testing group using the Gmail account associated with your watch - Join Google Group - 2. Join Beta Program - Opt-in to the internal testing program on Google Play - Join Beta Program - 3. Install on Watch + Install on Watch Open the Play Store listing directly on your watch to install Open on Watch From 622f0b48aa8ca89f1078ae9fbc32f09866274c76 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 11:11:18 +0530 Subject: [PATCH 20/24] feat: add local network permission support for watch wireless debugging --- app/src/main/AndroidManifest.xml | 1 + .../essentials/domain/model/AppPermission.kt | 7 ++++ .../domain/registry/FeatureRegistry.kt | 1 + .../domain/registry/PermissionRegistry.kt | 1 + .../watch/WatchWirelessDebuggingSettingsUI.kt | 34 +++++++++++++++++++ .../essentials/utils/PermissionUtils.kt | 32 +++++++++++++++++ .../essentials/utils/ui/PermissionUIHelper.kt | 11 ++++++ .../essentials/viewmodels/MainViewModel.kt | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 9 files changed, 91 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c2a85aacd..2c46738c9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/AppPermission.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/AppPermission.kt index 500e953bc..b8db78ab9 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/AppPermission.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/AppPermission.kt @@ -137,6 +137,12 @@ enum class AppPermission( titleRes = R.string.perm_sensitive_notifications_title, iconRes = R.drawable.rounded_vpn_key_24, ), + LOCAL_NETWORK( + key = "LOCAL_NETWORK", + titleRes = R.string.perm_local_network_title, + iconRes = R.drawable.rounded_lan_24, + aliases = listOf("ACCESS_LOCAL_NETWORK"), + ), NOTIFICATION_BUBBLES( key = "NOTIFICATION_BUBBLES", titleRes = R.string.perm_bubbles_title, @@ -152,6 +158,7 @@ enum class AppPermission( WRITE_SETTINGS -> PermissionUtils.canWriteSystemSettings(context) NOTIFICATION_POLICY -> PermissionUtils.hasNotificationPolicyAccess(context) SENSITIVE_NOTIFICATIONS -> PermissionGrantUtil.hasSensitiveNotifications(context) + LOCAL_NETWORK -> PermissionUtils.hasLocalNetworkPermission(context) ROOT, SHIZUKU -> ShellUtils.isAvailable(context) && ShellUtils.hasPermission(context) else -> true } 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 efb2a3a74..6cb266f0c 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 @@ -2640,6 +2640,7 @@ object FeatureRegistry { iconRes = R.drawable.rounded_adb_24, category = R.string.cat_tools, description = R.string.feat_watch_wireless_debugging_desc, + permissionKeys = listOf("LOCAL_NETWORK"), parentFeatureId = "Watch", hasMoreSettings = true, showToggle = true, 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 513dfcc00..4200c59d6 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 @@ -141,6 +141,7 @@ fun initPermissionRegistry() { // Post notifications permission PermissionRegistry.register("POST_NOTIFICATIONS", R.string.feat_caffeinate_title) + PermissionRegistry.register("LOCAL_NETWORK", R.string.feat_watch_wireless_debugging_title) PermissionRegistry.register("SENSITIVE_NOTIFICATIONS", R.string.island_otp_section) PermissionRegistry.register("POST_NOTIFICATIONS", R.string.feat_battery_notification_title) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/watch/WatchWirelessDebuggingSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/watch/WatchWirelessDebuggingSettingsUI.kt index 1a60fd3b3..6503bd118 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/watch/WatchWirelessDebuggingSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/watch/WatchWirelessDebuggingSettingsUI.kt @@ -12,6 +12,7 @@ package com.sameerasw.essentials.ui.features.watch import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.os.Build import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -27,6 +28,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -38,11 +40,13 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect import com.google.android.gms.wearable.Wearable 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.utils.HapticUtil +import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.viewmodels.MainViewModel @Composable @@ -104,6 +108,22 @@ fun WatchWirelessDebuggingSettingsUI( } } + LifecycleResumeEffect(Unit) { + viewModel.check(context) + onPauseOrDispose { } + } + + LaunchedEffect(Unit) { + if (Build.VERSION.SDK_INT >= 37 && + !PermissionUtils.hasLocalNetworkPermission(context) && + !prefs.getBoolean("local_network_requested", false) + ) { + PermissionUtils.requestLocalNetwork(context) + } + } + + val needsLocalNetwork = Build.VERSION.SDK_INT >= 37 && !viewModel.isLocalNetworkGranted.value + val isSsidAvailable = watchSsid.isNotBlank() val isIpAvailable = watchIp.isNotBlank() val isPortAvailable = watchPort > 0 @@ -115,6 +135,20 @@ fun WatchWirelessDebuggingSettingsUI( .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + if (needsLocalNetwork) { + RoundedCardContainer { + IconToggleItem( + iconRes = R.drawable.rounded_lan_24, + title = stringResource(R.string.perm_local_network_title), + description = stringResource(R.string.perm_local_network_desc), + showToggle = false, + onClick = { + HapticUtil.performUIHaptic(view) + PermissionUtils.requestLocalNetwork(context) + }, + ) + } + } RoundedCardContainer { IconToggleItem( iconRes = R.drawable.rounded_adb_24, diff --git a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt index 91af2ac7a..78435402a 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt @@ -150,6 +150,38 @@ object PermissionUtils { false } + const val LOCAL_NETWORK_PERMISSION = "android.permission.ACCESS_LOCAL_NETWORK" + + fun hasLocalNetworkPermission(context: Context): Boolean = + Build.VERSION.SDK_INT < 37 || + androidx.core.content.ContextCompat.checkSelfPermission(context, LOCAL_NETWORK_PERMISSION) == + android.content.pm.PackageManager.PERMISSION_GRANTED + + fun requestLocalNetwork( + context: Context, + activity: android.app.Activity? = null, + ) { + val target = activity ?: generateSequence(context) { (it as? android.content.ContextWrapper)?.baseContext } + .filterIsInstance() + .firstOrNull() + val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + val askedBefore = prefs.getBoolean("local_network_requested", false) + if (target == null || + (askedBefore && !androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale(target, LOCAL_NETWORK_PERMISSION)) + ) { + context.startActivity( + android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(android.net.Uri.fromParts("package", context.packageName, null)) + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), + ) + return + } + prefs.edit().putBoolean("local_network_requested", true).apply() + androidx.core.app.ActivityCompat.requestPermissions(target, arrayOf(LOCAL_NETWORK_PERMISSION), LOCAL_NETWORK_REQUEST_CODE) + } + + private const val LOCAL_NETWORK_REQUEST_CODE = 137 + fun hasBubblePermission(context: Context): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return false val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? android.app.NotificationManager ?: return false diff --git a/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt b/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt index 313a8d477..1771a5b99 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt @@ -518,6 +518,17 @@ object PermissionUIHelper { ) } + AppPermission.LOCAL_NETWORK -> + PermissionItem( + iconRes = permission.iconRes, + title = permission.titleRes, + description = R.string.perm_local_network_desc, + dependentFeatures = PermissionRegistry.getFeatures(permission), + actionLabel = if (viewModel.isLocalNetworkGranted.value) R.string.perm_action_granted else R.string.perm_action_grant, + action = { PermissionUtils.requestLocalNetwork(context, activity) }, + isGranted = viewModel.isLocalNetworkGranted.value, + ) + AppPermission.SENSITIVE_NOTIFICATIONS -> PermissionItem( iconRes = permission.iconRes, 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 aa649b3c2..d222d7180 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -607,6 +607,7 @@ class MainViewModel : ViewModel() { val isCalendarPermissionGranted = mutableStateOf(false) val isBubblePermissionGranted = mutableStateOf(false) val isSensitiveNotificationsGranted = mutableStateOf(true) + val isLocalNetworkGranted = mutableStateOf(true) val isUserDictionaryEnabled = mutableStateOf(false) val userDictionaryWords = mutableStateOf>(emptyMap()) val isUserDictionarySheetVisible = mutableStateOf(false) @@ -1872,6 +1873,7 @@ class MainViewModel : ViewModel() { isWriteSecureSettingsEnabled.value = PermissionUtils.canWriteSecureSettings(context) isBubblePermissionGranted.value = PermissionUtils.hasBubblePermission(context) isSensitiveNotificationsGranted.value = PermissionGrantUtil.hasSensitiveNotifications(context) + isLocalNetworkGranted.value = PermissionUtils.hasLocalNetworkPermission(context) isShizukuAvailable.value = ShizukuUtils.isShizukuAvailable() isShizukuPermissionGranted.value = ShizukuUtils.hasPermission() if (cachedIsUpdateAvailable) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d1d52149c..3c0b97c3a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2981,6 +2981,8 @@ More Show granted Hide granted + Local network access + Android 17 blocks apps from talking to devices on your Wi-Fi network unless you allow it. Essentials needs this to work with your watch over the local network. Floating Bubbles Read sensitive notifications Android hides one-time codes from notification listeners. Essentials needs this access to show codes on the island. From 74daf5534a14c6a4d2943290dbf123bf70e6cb97 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 11:13:37 +0530 Subject: [PATCH 21/24] feat: reorder what's new sections and add previous updates divider --- .../ui/components/WhatsNewCustomContent.kt | 37 +++++++++++-------- app/src/main/res/values/strings.xml | 2 + 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt index fc1786431..32cf8972d 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt @@ -76,6 +76,28 @@ fun WhatsNewCustomContent( verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth(), ) { + WhatsNewSectionCard( + title = stringResource(R.string.whats_new_watchface_title), + summary = stringResource(R.string.whats_new_watchface_summary), + expanded = expandedSection == 1, + onToggle = { toggle(1) }, + icon = { SectionIcon(R.drawable.rounded_watch_24) }, + ) { + Text( + text = stringResource(R.string.whats_new_watchface_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + EssentialsWatchfacePromoContent() + } + + Text( + text = stringResource(R.string.whats_new_previous_updates), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp, top = 8.dp), + ) + WhatsNewSectionCard( title = stringResource(R.string.whats_new_overcast_card_title), summary = stringResource(R.string.whats_new_overcast_section_title), @@ -118,21 +140,6 @@ fun WhatsNewCustomContent( ) } - WhatsNewSectionCard( - title = stringResource(R.string.whats_new_watchface_title), - summary = stringResource(R.string.whats_new_watchface_summary), - expanded = expandedSection == 1, - onToggle = { toggle(1) }, - icon = { SectionIcon(R.drawable.rounded_watch_24) }, - ) { - Text( - text = stringResource(R.string.whats_new_watchface_desc), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - EssentialsWatchfacePromoContent() - } - WhatsNewSectionCard( title = stringResource(R.string.setting_hidden_debugging_title), summary = stringResource(R.string.whats_new_hidden_debugging_summary), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3c0b97c3a..6a29093bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3505,6 +3505,8 @@ Open the Play Store listing directly on your watch to install Open on Watch + From previous updates + Weather is Now Separate Weather has been separated into my brand new app, Overcast. Install to continue having weather forecasts and effects in Island brief. From ab2de0ae656c88d396e23f773461be8b2fb37db3 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 11:19:48 +0530 Subject: [PATCH 22/24] feat: add Watch Wireless Debugging feature settings and simplify local network permission flow --- .../ui/activities/FeatureSettingsActivity.kt | 2 ++ .../essentials/utils/PermissionUtils.kt | 17 +++-------------- 2 files changed, 5 insertions(+), 14 deletions(-) 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 062292c61..c55d1d67e 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 @@ -383,6 +383,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Button remap" -> !isAccessibilityEnabled "Face unlock brightness" -> !isAccessibilityEnabled "Screenshots" -> !isAccessibilityEnabled || !viewModel.isStoragePermissionGranted.value + "Watch Wireless Debugging" -> android.os.Build.VERSION.SDK_INT >= 37 && !viewModel.isLocalNetworkGranted.value "Lock screen shortcuts" -> !isAccessibilityEnabled "Volume steps" -> !isAccessibilityEnabled "App volume" -> !isAccessibilityEnabled || !isShizukuPermissionGranted @@ -843,6 +844,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Button remap" -> !isAccessibilityEnabled "Face unlock brightness" -> !isAccessibilityEnabled "Screenshots" -> !isAccessibilityEnabled || !viewModel.isStoragePermissionGranted.value + "Watch Wireless Debugging" -> android.os.Build.VERSION.SDK_INT >= 37 && !viewModel.isLocalNetworkGranted.value "Lock screen shortcuts" -> !isAccessibilityEnabled "Volume steps" -> !isAccessibilityEnabled "App volume" -> !isAccessibilityEnabled || !isShizukuPermissionGranted diff --git a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt index 78435402a..32c1cc30b 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt @@ -163,20 +163,9 @@ object PermissionUtils { ) { val target = activity ?: generateSequence(context) { (it as? android.content.ContextWrapper)?.baseContext } .filterIsInstance() - .firstOrNull() - val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) - val askedBefore = prefs.getBoolean("local_network_requested", false) - if (target == null || - (askedBefore && !androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale(target, LOCAL_NETWORK_PERMISSION)) - ) { - context.startActivity( - android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - .setData(android.net.Uri.fromParts("package", context.packageName, null)) - .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), - ) - return - } - prefs.edit().putBoolean("local_network_requested", true).apply() + .firstOrNull() ?: return + context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + .edit().putBoolean("local_network_requested", true).apply() androidx.core.app.ActivityCompat.requestPermissions(target, arrayOf(LOCAL_NETWORK_PERMISSION), LOCAL_NETWORK_REQUEST_CODE) } From a02341e09523d090c53e2a4b0baa855ded16546f Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 11:21:21 +0530 Subject: [PATCH 23/24] refactor: add bottom content padding to SoundModeTileSettingsUI lazy list --- .../essentials/ui/features/audio/SoundModeTileSettingsUI.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/audio/SoundModeTileSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/audio/SoundModeTileSettingsUI.kt index ab20c7670..7c13fee0e 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/audio/SoundModeTileSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/audio/SoundModeTileSettingsUI.kt @@ -13,6 +13,7 @@ import android.content.Intent import android.net.Uri import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -178,6 +179,7 @@ fun SoundModeTileSettingsUI( .scrollMotionBlur(lazyListState, enabled = isMotionBlurEnabled) .padding(horizontal = 16.dp), state = lazyListState, + contentPadding = PaddingValues(bottom = 160.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { items(activeModes.size, key = { activeModes[it] }) { index -> From 43541a4f76576195451a1b78a3f075ccd7fb4bdd Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 11:22:19 +0530 Subject: [PATCH 24/24] fix: persist search query and tab state across config changes --- .../essentials/ui/activities/PixelSearchResultsActivity.kt | 5 +++-- .../com/sameerasw/essentials/ui/composables/SetupFeatures.kt | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/PixelSearchResultsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/PixelSearchResultsActivity.kt index aab20b7d8..90f2d5492 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/PixelSearchResultsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/PixelSearchResultsActivity.kt @@ -78,6 +78,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -219,9 +220,9 @@ fun PixelSearchResultsScreen( val keyboardController = LocalSoftwareKeyboardController.current val density = LocalDensity.current - var query by remember { mutableStateOf(initialQuery) } + var query by rememberSaveable { mutableStateOf(initialQuery) } val focusRequester = remember { FocusRequester() } - var selectedTab by remember { mutableStateOf(PixelSearchTab.ALL) } + var selectedTab by rememberSaveable { mutableStateOf(PixelSearchTab.ALL) } val isAllTab = selectedTab == PixelSearchTab.ALL var dragOffsetY by remember { mutableFloatStateOf(0f) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt index afddc3254..ff604101f 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt @@ -1363,7 +1363,7 @@ fun SetupFeatures( viewModel = viewModel, ) } - } else if (isFocused && searchQuery.isNotEmpty()) { + } else if (searchQuery.isNotEmpty()) { if (!isSearchingViewModel && searchResults.isEmpty() && matchingTiles.isEmpty() && matchingPermissions.isEmpty() && matchingFrozenApps.isEmpty()) { item { Column(