From 911819151aa7dca4800f3f30d4625efc3950f301 Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Wed, 14 Oct 2026 13:16:30 -0600 Subject: [PATCH 1/5] 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 2/5] 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 a815bebcaa979ada4e84b485b7a5dfd6899e83d6 Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Sat, 17 Oct 2026 21:34:34 -0700 Subject: [PATCH 3/5] 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 b1c50b317837a8ed2a08b5380b9d0a449b74e63f Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 10:20:49 +0530 Subject: [PATCH 4/5] 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 5/5] 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