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 9d5b02c2d..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 @@ -680,6 +680,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" @@ -4396,6 +4401,14 @@ 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, 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) + 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/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/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 33ec65e0c..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 @@ -1089,6 +1089,31 @@ 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_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, + "island_otp_bypass_filter", + ), ), ) { override fun isEnabled(viewModel: MainViewModel) = viewModel.isIslandEnabled.value 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/island/model/IslandModels.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt index 4441c1e29..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,6 +77,7 @@ class InteractionOverrides( val onTap: (() -> Boolean)? = null, val onLongPress: (() -> Unit)? = null, val onExpandedTap: (() -> Boolean)? = null, + 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 cc10d9ae4..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 @@ -30,6 +30,7 @@ import com.sameerasw.essentials.island.plugins.soften import com.sameerasw.essentials.island.ui.IslandStackBubble import com.sameerasw.essentials.island.ui.IslandStackOverlap 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 @@ -53,6 +54,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() @@ -149,6 +155,10 @@ class NotificationsPlugin : BaseIslandPlugin() { private val timeoutRunnable = Runnable { onTimeout() } private val catchUpRunnable = Runnable { clearAll() } + private var copiedKey: String? = null + private var postedWhileCopied = false + private val copiedRunnable = Runnable { finishCopy() } + private val listener = object : NotificationListener.NotificationAlertListener { override fun onNotificationAlertPosted(alert: ActiveNotificationAlert) { ctx?.mainHandler?.post { onPosted(alert) } @@ -195,10 +205,22 @@ 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 + } + + 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 @@ -212,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()) { @@ -255,10 +283,11 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun popUp() { val c = ctx ?: return if (minimal()) return + val otpNeedsExpand = currentAlert()?.let(::isOtp) == true && !settings.isIslandOtpTapToCopyEnabled() + val lineAvailable = settings.isIslandLineStageEnabled() && settings.isIslandNotifCompactHeadsUpEnabled() && !otpNeedsExpand markSeen() - val lineAvailable = settings.isIslandLineStageEnabled() && settings.isIslandNotifCompactHeadsUpEnabled() 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 }, ) } @@ -319,16 +348,19 @@ 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 + postedWhileCopied = false cancelTimers() alerts.clear() seen.clear() @@ -358,6 +390,10 @@ class NotificationsPlugin : BaseIslandPlugin() { } private fun render() { + if (copiedKey != null) { + publish(copiedItem()) + return + } val alert = currentAlert() if (alert == null) { publish(null) @@ -435,6 +471,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() @@ -512,12 +550,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 = { @@ -531,6 +578,108 @@ 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) currentIndex = alerts.removeKeepingCurrent(index, currentIndex) + if (alerts.isEmpty()) { + 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() + } + 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 -> @@ -592,6 +741,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..e24d719b3 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpClipboard.kt @@ -0,0 +1,29 @@ +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) + 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..f64620909 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExpanded.kt @@ -0,0 +1,135 @@ +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 + +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..1a9a87923 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/OtpExtractor.kt @@ -0,0 +1,89 @@ +package com.sameerasw.essentials.island.plugins.notifications + +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( + Regex("\\b[A-Z]{1,3}-(\\d{4,8})\\b"), + 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 (CURRENCY_AFTER.containsMatchIn(text.substring(range.last + 1, minOf(text.length, range.last + 12)))) 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/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/island/state/IslandController.kt b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt index 92343e982..4b7b46ac5 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 @@ -117,7 +117,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 3764d5f6c..b43c6c645 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt @@ -51,6 +51,7 @@ import com.sameerasw.essentials.domain.model.NavigationNotificationData import com.sameerasw.essentials.domain.model.NavigationParser import com.sameerasw.essentials.domain.model.ProgressNotificationData import com.sameerasw.essentials.domain.model.RippleConfig +import com.sameerasw.essentials.island.plugins.notifications.OtpExtractor import com.sameerasw.essentials.services.receivers.FlashlightActionReceiver import com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService import com.sameerasw.essentials.services.widgets.PixelSearchbarWidget @@ -82,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 = @@ -306,6 +319,24 @@ 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( + 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 { @@ -1058,6 +1089,7 @@ class NotificationListener : NotificationListenerService() { val alert = extractNotificationAlert(sbn)?.copy( isSilent = isSilentNotification(sbn, rankingMap), blockedByDnd = isBlockedByDnd(sbn, rankingMap), + 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 0815d238a..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 @@ -894,6 +894,7 @@ fun IslandNotificationSettingsUI( val context = LocalContext.current val view = LocalView.current var showFilterAppsSheet by remember { mutableStateOf(false) } + var showOtpPermissions by remember { mutableStateOf(false) } var showQueueSheet by remember { mutableStateOf( viewModel.isIslandNotifQueue.value && @@ -1147,6 +1148,90 @@ 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( + iconRes = R.drawable.rounded_vpn_key_24, + title = stringResource(R.string.island_otp_enabled_title), + isChecked = otpEnabled, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + if (checked && !viewModel.isSensitiveNotificationsGranted.value) { + showOtpPermissions = true + } else { + viewModel.setIslandOtpEnabled(checked) + } + }, + modifier = Modifier.highlight(highlightSetting == "island_otp_enabled"), + ) + AnimatedVisibility( + visible = otpEnabled, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + 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"), + ) + } + } + if (rememberIslandShowsWhileLocked()) { IslandSettingsSection(R.string.island_notif_section_lock_screen) { IslandPrefToggle( @@ -1209,6 +1294,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/utils/PermissionGrantUtil.kt b/app/src/main/java/com/sameerasw/essentials/utils/PermissionGrantUtil.kt index b7fb3afa2..231726804 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 @@ -51,6 +53,32 @@ object PermissionGrantUtil { return true } + private const val OP_SENSITIVE_NOTIFICATIONS = "android:receive_sensitive_notifications" + + 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 + } + } + + 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, + ) + if (!PermissionUtils.hasNotificationListenerPermission(context)) { + ShellUtils.runCommandWithOutput(context, "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/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 1bbf8b5ba..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,6 +290,8 @@ class MainViewModel : ViewModel() { val isIslandNotifTapToOpen = mutableStateOf(false) val isIslandCatchUpEnabled = mutableStateOf(true) val islandCatchUpTimeoutMs = mutableLongStateOf(10000L) + val isIslandOtpEnabled = mutableStateOf(false) + val islandOtpTimeoutMs = mutableLongStateOf(60_000L) val isIslandShowGlow = mutableStateOf(true) val isIslandBorderOutline = mutableStateOf(false) val islandBorderOutlineColor = mutableStateOf(SettingsRepository.ISLAND_BORDER_OUTLINE_DEFAULT_COLOR) @@ -602,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) @@ -998,6 +1002,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() @@ -1860,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) { @@ -2533,6 +2544,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() @@ -6050,6 +6063,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 a43bbba85..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 @@ -3442,6 +3445,22 @@ 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 + 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 + Allow reading codes 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..6558c56b1 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/island/OtpExtractorTest.kt @@ -0,0 +1,83 @@ +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() { + 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%")) + 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")) + } +} 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) + } +}