diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db5470926e..ccd58568ff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -115,10 +115,10 @@ android { applicationId = "com.sameerasw.essentials" minSdk = 26 targetSdk = 37 - versionCode = 68 - versionName = "19.1-beta.3" + versionCode = 69 + versionName = "20.0" - val whatsNewCounter = 6 + val whatsNewCounter = 7 buildConfigField("int", "WHATS_NEW_COUNTER", whatsNewCounter.toString()) buildConfigField("int", "REQUIRED_WEAR_VERSION_CODE", "7") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c2a85aacdd..2c46738c94 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 73f75474f4..0550e28d7c 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -26,6 +26,7 @@ import com.sameerasw.essentials.domain.model.AppSelection import com.sameerasw.essentials.domain.model.BackGestureAppConfig import com.sameerasw.essentials.domain.model.AppTag import com.sameerasw.essentials.domain.model.DnsPreset +import com.sameerasw.essentials.utils.DuoBatteryLevels import com.sameerasw.essentials.domain.model.HilightEffect import com.sameerasw.essentials.domain.model.HilightProgressColorMode import com.sameerasw.essentials.domain.model.NotificationLightingColorMode @@ -493,6 +494,8 @@ class SettingsRepository( const val KEY_ISLAND_BATTERY_IDLE_COLOR = "island_battery_idle_color" const val KEY_DUO_BATTERY_CRITICAL_COLOR_ENABLED = "duo_battery_critical_color_enabled" const val KEY_DUO_BATTERY_CRITICAL_COLOR = "duo_battery_critical_color" + const val KEY_DUO_BATTERY_LOW_LEVEL = "duo_battery_low_level" + const val KEY_DUO_BATTERY_CRITICAL_LEVEL = "duo_battery_critical_level" const val KEY_DUO_SHOW_NETWORKS = "duo_show_networks" const val KEY_DUO_DIFFERENTIATE_WIFI = "duo_differentiate_wifi" const val KEY_DUO_SHOW_TIME = "duo_show_time" @@ -587,6 +590,8 @@ class SettingsRepository( const val KEY_ISLAND_DISABLE_INTERACTIONS_IN_FULLSCREEN = "island_disable_interactions_in_fullscreen" const val KEY_ISLAND_LANDSCAPE_TOP_SPACING = "island_landscape_top_spacing" const val KEY_ISLAND_BOND_EDGE = "island_bond_edge" + const val KEY_ISLAND_BALANCE_COMPACT = "island_balance_compact" + const val KEY_ISLAND_SECONDARY_BUBBLE = "island_secondary_bubble" const val KEY_ISLAND_MAX_ITEMS = "island_max_items" const val KEY_ISLAND_ALWAYS_GESTURES = "island_always_gestures" const val KEY_ISLAND_DISMISS_ON_OUTSIDE = "island_dismiss_on_outside" @@ -676,6 +681,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" @@ -3903,6 +3913,14 @@ class SettingsRepository( fun getDuoBatteryCriticalColor(): String = getString(KEY_DUO_BATTERY_CRITICAL_COLOR, "#F44336") ?: "#F44336" fun setDuoBatteryCriticalColor(colorHex: String) = putString(KEY_DUO_BATTERY_CRITICAL_COLOR, colorHex) + fun getDuoBatteryLowLevel(): Int = getInt(KEY_DUO_BATTERY_LOW_LEVEL, DuoBatteryLevels.DEFAULT_LOW) + fun getDuoBatteryCriticalLevel(): Int = getInt(KEY_DUO_BATTERY_CRITICAL_LEVEL, DuoBatteryLevels.DEFAULT_CRITICAL) + + fun setDuoBatteryLevels(low: Int, critical: Int) { + putInt(KEY_DUO_BATTERY_LOW_LEVEL, low) + putInt(KEY_DUO_BATTERY_CRITICAL_LEVEL, critical) + } + fun isDuoShowNetworksEnabled(): Boolean = getBoolean(KEY_DUO_SHOW_NETWORKS, true) fun setDuoShowNetworksEnabled(enabled: Boolean) = putBoolean(KEY_DUO_SHOW_NETWORKS, enabled) @@ -4279,6 +4297,14 @@ class SettingsRepository( fun setIslandBondEdgeEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_BOND_EDGE, enabled) + fun isIslandBalanceCompactEnabled(): Boolean = getBoolean(KEY_ISLAND_BALANCE_COMPACT, true) + + fun setIslandBalanceCompactEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_BALANCE_COMPACT, enabled) + + fun isIslandSecondaryBubbleEnabled(): Boolean = getBoolean(KEY_ISLAND_SECONDARY_BUBBLE, true) + + fun setIslandSecondaryBubbleEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_SECONDARY_BUBBLE, enabled) + fun getIslandLandscapeTopSpacing(): Float = getFloat(KEY_ISLAND_LANDSCAPE_TOP_SPACING, 0f) fun setIslandLandscapeTopSpacing(value: Float) = putFloat(KEY_ISLAND_LANDSCAPE_TOP_SPACING, value) @@ -4380,6 +4406,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 78250276cb..c7497f26e5 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 0f4dec1c8c..b8db78ab96 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,17 @@ 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, + ), + LOCAL_NETWORK( + key = "LOCAL_NETWORK", + titleRes = R.string.perm_local_network_title, + iconRes = R.drawable.rounded_lan_24, + aliases = listOf("ACCESS_LOCAL_NETWORK"), + ), NOTIFICATION_BUBBLES( key = "NOTIFICATION_BUBBLES", titleRes = R.string.perm_bubbles_title, @@ -145,6 +157,8 @@ enum class AppPermission( DRAW_OVERLAYS -> PermissionUtils.canDrawOverlays(context) WRITE_SETTINGS -> PermissionUtils.canWriteSystemSettings(context) NOTIFICATION_POLICY -> PermissionUtils.hasNotificationPolicyAccess(context) + SENSITIVE_NOTIFICATIONS -> PermissionGrantUtil.hasSensitiveNotifications(context) + LOCAL_NETWORK -> PermissionUtils.hasLocalNetworkPermission(context) ROOT, SHIZUKU -> ShellUtils.isAvailable(context) && ShellUtils.hasPermission(context) else -> true } diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 220349b979..6cb266f0c7 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -1008,6 +1008,16 @@ object FeatureRegistry { R.string.island_line_peek_desc, "island_line_stage_enabled", ), + SearchSetting( + R.string.island_secondary_bubble_title, + R.string.island_section_behavior, + "island_secondary_bubble", + ), + SearchSetting( + R.string.island_balance_compact_title, + R.string.island_section_behavior, + "island_balance_compact", + ), ), ) { override fun isEnabled(viewModel: MainViewModel) = true @@ -1084,6 +1094,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 @@ -2605,6 +2640,7 @@ object FeatureRegistry { iconRes = R.drawable.rounded_adb_24, category = R.string.cat_tools, description = R.string.feat_watch_wireless_debugging_desc, + permissionKeys = listOf("LOCAL_NETWORK"), parentFeatureId = "Watch", hasMoreSettings = true, showToggle = true, diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt index 10d21f9f7d..4200c59d60 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,8 @@ fun initPermissionRegistry() { // Post notifications permission PermissionRegistry.register("POST_NOTIFICATIONS", R.string.feat_caffeinate_title) + PermissionRegistry.register("LOCAL_NETWORK", R.string.feat_watch_wireless_debugging_title) + PermissionRegistry.register("SENSITIVE_NOTIFICATIONS", R.string.island_otp_section) PermissionRegistry.register("POST_NOTIFICATIONS", R.string.feat_battery_notification_title) // 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 4441c1e29c..0c4b809684 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/model/IslandPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandPlugin.kt index 3b4ceaa206..af1922418c 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/model/IslandPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/model/IslandPlugin.kt @@ -27,6 +27,7 @@ interface IslandPlugin { fun stop() fun refresh() {} fun onScreenStateChanged() = refresh() + fun onSuppressionChanged(suppressed: Boolean) {} fun onUserInteraction(focusedKey: String?) {} fun onFocusChanged(stage: IslandStage, focusedKey: String?) {} } diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/batteryalerts/BatteryAlertsPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/batteryalerts/BatteryAlertsPlugin.kt index e2c52f44a8..9eff4d6694 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/batteryalerts/BatteryAlertsPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/batteryalerts/BatteryAlertsPlugin.kt @@ -24,6 +24,7 @@ class BatteryAlertsPlugin : BaseIslandPlugin() { override val id = "battery_alerts_plugin" override val settingKeys = setOf( + SettingsRepository.KEY_ISLAND_SHOW_BATTERY, SettingsRepository.KEY_ISLAND_SHOW_BATTERY_ALERTS, SettingsRepository.KEY_ISLAND_BATTERY_ALERT_CHARGING, SettingsRepository.KEY_ISLAND_BATTERY_ALERT_LOW, @@ -55,7 +56,10 @@ class BatteryAlertsPlugin : BaseIslandPlugin() { readBattery(intent) val newAlertState = currentAlertState() - if ((isCharging && !wasCharging && newAlertState == "charging") || (newAlertState != prevAlertState && newAlertState.isNotEmpty())) { + if (!enabled()) { + if (peeking) stopPeek() + render() + } else if ((isCharging && !wasCharging && newAlertState == "charging") || (newAlertState != prevAlertState && newAlertState.isNotEmpty())) { peekAlert() } else if (!peeking) { render() @@ -80,9 +84,18 @@ class BatteryAlertsPlugin : BaseIslandPlugin() { override fun refresh() { readBattery(null) + if (!enabled() && peeking) stopPeek() render() } + private fun enabled() = + settings.isIslandShowBatteryEnabled() && settings.isIslandShowBatteryAlertsEnabled() + + private fun stopPeek() { + ctx?.mainHandler?.removeCallbacks(endPeek) + peeking = false + } + private fun readBattery(intent: Intent?) { val batteryIntent = intent ?: context.registerReceiver( null, IntentFilter(Intent.ACTION_BATTERY_CHANGED) @@ -140,6 +153,7 @@ class BatteryAlertsPlugin : BaseIslandPlugin() { private fun peekAlert() { val c = ctx ?: return + if (!enabled()) return val duration = settings.getIslandPeekDurationMs() peeking = true render() @@ -154,8 +168,7 @@ class BatteryAlertsPlugin : BaseIslandPlugin() { return } - val masterEnabled = settings.getBoolean(SettingsRepository.KEY_ISLAND_SHOW_BATTERY_ALERTS, true) - if (!masterEnabled || !peeking || level < 0) { + if (!enabled() || !peeking || level < 0) { publish(emptyList()) return } diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/call/CallPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/call/CallPlugin.kt index 9b8610c902..3daeeef5fa 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/call/CallPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/call/CallPlugin.kt @@ -4,6 +4,7 @@ import com.sameerasw.essentials.island.plugins.sendPendingIntent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp +import android.telecom.TelecomManager import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.island.model.CompactCell @@ -178,104 +179,99 @@ class CallPlugin : BaseIslandPlugin() { } private fun buttonsFor(snap: CallSnapshot, ringing: Boolean): List { - val telephony = snap.fromTelephony - val canAnswer = snap.answerIntent != null || telephony - val canEnd = snap.endIntent != null || telephony - val buttons = mutableListOf() - fun end(label: Int) { - if (!canEnd) return - buttons += CallButtonSpec( - key = "end", - icon = R.drawable.rounded_call_end_24, - label = context.getString(label), - style = CallButtonStyle.End, - onClick = { - guarded { - if (telephony) CallControlUtil.endCall(context) - else if (!sendPendingIntent(context, snap.endIntent)) CallControlUtil.endCall(context) - } - null - }, - ) - } - fun answer() { - if (!canAnswer) return - buttons += CallButtonSpec( - key = "answer", - icon = R.drawable.rounded_call_24, - label = context.getString(R.string.island_call_answer), - style = CallButtonStyle.Answer, - onClick = { - guarded { - if (telephony) CallControlUtil.acceptCall(context) - else if (!sendPendingIntent(context, snap.answerIntent)) CallControlUtil.acceptCall(context) - } - null - }, - ) - } - if (ringing) { - end(R.string.island_call_decline) - answer() - } else { - val own = snap.extraActions - val mute = own.firstOrNull { it.role == CallActionRole.Mute } - val speaker = own.firstOrNull { it.role == CallActionRole.Speaker } - if (telephony) { - buttons += CallButtonSpec( - key = "mute", - icon = R.drawable.rounded_mic_24, - onIcon = R.drawable.rounded_mic_off_24, - label = context.getString(R.string.island_call_mute), - initialOn = guarded(false) { CallControlUtil.isMuted(context) }, - onClick = { guarded(null) { CallControlUtil.toggleMute(context) } }, - ) - buttons += CallButtonSpec( - key = "speaker", - icon = R.drawable.rounded_volume_up_24, - label = context.getString(R.string.island_call_speaker), - initialOn = guarded(false) { CallControlUtil.isSpeakerOn(context) }, - onClick = { guarded(null) { CallControlUtil.toggleSpeaker(context) } }, - ) - } else { - if (mute != null) { - buttons += ownButton(mute, R.drawable.rounded_mic_24, on = mute.title.lowercase().contains("unmute"), onIcon = R.drawable.rounded_mic_off_24) - } - if (speaker != null) { - buttons += ownButton(speaker, R.drawable.rounded_volume_up_24, on = false) - } + val buttons = if (usesOwnControls(snap)) notificationButtons(snap, ringing) else telephonyButtons(snap, ringing) + if (buttons.isNotEmpty()) return buttons + return listOf(openButton(snap)) + } + + private fun usesOwnControls(snap: CallSnapshot): Boolean { + val hasOwn = snap.allActions.isNotEmpty() || snap.answerIntent != null || snap.endIntent != null + if (!hasOwn) return false + if (!snap.fromTelephony) return true + val dialer = guarded(null) { context.getSystemService(TelecomManager::class.java)?.defaultDialerPackage } + return snap.packageName != null && snap.packageName != dialer + } + + private fun telephonyButtons(snap: CallSnapshot, ringing: Boolean): List { + val end = CallButtonSpec( + key = "end", + icon = R.drawable.rounded_call_end_24, + label = context.getString(if (ringing) R.string.island_call_decline else R.string.island_call_end), + style = CallButtonStyle.End, + onClick = { + guarded { CallControlUtil.endCall(context) } + null + }, + ) + if (!ringing) return listOf(openButton(snap), end) + val answer = CallButtonSpec( + key = "answer", + icon = R.drawable.rounded_call_24, + label = context.getString(R.string.island_call_answer), + style = CallButtonStyle.Answer, + onClick = { + guarded { CallControlUtil.acceptCall(context) } + null + }, + ) + return listOf(end, answer) + } + + private fun notificationButtons(snap: CallSnapshot, ringing: Boolean): List { + val actions = snap.allActions.ifEmpty { legacyActions(snap, ringing) } + return actions.take(MAX_NOTIFICATION_BUTTONS).map { action -> + val style = when (action.role) { + CallActionRole.Answer -> CallButtonStyle.Answer + CallActionRole.Decline, CallActionRole.HangUp -> CallButtonStyle.End + else -> CallButtonStyle.Neutral } - own.filter { it.role != CallActionRole.Mute && it.role != CallActionRole.Speaker } - .take(MAX_EXTRA_BUTTONS) - .forEach { action -> - val icon = when (action.role) { - CallActionRole.Hold -> R.drawable.rounded_pause_24 - CallActionRole.Video -> R.drawable.rounded_videocam_24 - else -> null - } - buttons += ownButton(action, icon, on = false) - } - end(R.string.island_call_end) + val icon = when (action.role) { + CallActionRole.Answer -> R.drawable.rounded_call_24 + CallActionRole.Decline, CallActionRole.HangUp -> R.drawable.rounded_call_end_24 + CallActionRole.Mute -> R.drawable.rounded_mic_24 + CallActionRole.Speaker -> R.drawable.rounded_volume_up_24 + CallActionRole.Hold -> R.drawable.rounded_pause_24 + CallActionRole.Video -> R.drawable.rounded_videocam_24 + CallActionRole.Other -> null + } + val on = action.role == CallActionRole.Mute && action.title.lowercase().contains("unmute") + ownButton(action, icon, on = on, onIcon = if (action.role == CallActionRole.Mute) R.drawable.rounded_mic_off_24 else null, style = style) } - if (buttons.isEmpty()) { - buttons += CallButtonSpec( - key = "open", - icon = R.drawable.rounded_open_in_new_24, - label = context.getString(R.string.island_call_open), - onClick = { - guarded { if (!sendPendingIntent(context, snap.contentIntent)) CallControlUtil.showInCallScreen(context) } - null - }, - ) + } + + private fun legacyActions(snap: CallSnapshot, ringing: Boolean): List = buildList { + val answer = snap.answerIntent + val end = snap.endIntent + if (ringing) { + if (end != null) add(CallNotificationAction(CallActionRole.Decline, context.getString(R.string.island_call_decline), end)) + if (answer != null) add(CallNotificationAction(CallActionRole.Answer, context.getString(R.string.island_call_answer), answer)) + } else if (end != null) { + add(CallNotificationAction(CallActionRole.HangUp, context.getString(R.string.island_call_end), end)) } - return buttons } - private fun ownButton(action: CallNotificationAction, icon: Int?, on: Boolean, onIcon: Int? = null) = CallButtonSpec( + private fun openButton(snap: CallSnapshot) = CallButtonSpec( + key = "open", + icon = R.drawable.rounded_open_in_new_24, + label = context.getString(R.string.island_call_open), + onClick = { + guarded { if (!sendPendingIntent(context, snap.contentIntent)) CallControlUtil.showInCallScreen(context) } + null + }, + ) + + private fun ownButton( + action: CallNotificationAction, + icon: Int?, + on: Boolean, + onIcon: Int? = null, + style: CallButtonStyle = CallButtonStyle.Neutral, + ) = CallButtonSpec( key = "own:${action.role}:${action.title}", icon = icon, onIcon = onIcon, label = action.title, + style = style, initialOn = on, onClick = { guarded { sendPendingIntent(context, action.intent) } @@ -307,7 +303,7 @@ class CallPlugin : BaseIslandPlugin() { companion object { const val ITEM_KEY = "call" - private const val MAX_EXTRA_BUTTONS = 2 + private const val MAX_NOTIFICATION_BUTTONS = 3 } } diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/consciousgate/ConsciousGatePlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/consciousgate/ConsciousGatePlugin.kt index 7d97b23d9a..cbc930f873 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/consciousgate/ConsciousGatePlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/consciousgate/ConsciousGatePlugin.kt @@ -2,6 +2,7 @@ package com.sameerasw.essentials.island.plugins.consciousgate import com.sameerasw.essentials.island.ui.components.MarqueeText import android.content.Context +import android.os.PowerManager import android.graphics.Bitmap import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -108,7 +109,8 @@ class ConsciousGatePlugin : BaseIslandPlugin() { if (lastHapticSecond != totalSec) { lastHapticSecond = totalSec val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) - if (prefs.getBoolean("conscious_gate_feel_every_second", false)) { + val interactive = context.getSystemService(PowerManager::class.java)?.isInteractive != false + if (interactive && prefs.getBoolean("conscious_gate_feel_every_second", false)) { HapticUtil.performHapticForService(context, HapticFeedbackType.SUBTLE) } } diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/devices/DevicesPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/devices/DevicesPlugin.kt index 57015da85a..4e4682beb8 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/devices/DevicesPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/devices/DevicesPlugin.kt @@ -58,6 +58,8 @@ class DevicesPlugin : BaseIslandPlugin() { private var batteryDevice: BatteryDevice? = null private var registered = false private val pendingDisconnects = mutableMapOf() + private val pendingConnects = mutableMapOf() + private val announcedConnected = mutableSetOf() private val clearEvent = Runnable { event = null render() @@ -73,14 +75,28 @@ class DevicesPlugin : BaseIslandPlugin() { if (pending != null) { ctx?.mainHandler?.removeCallbacks(pending) } else { - announce(device, connected = true) + pendingConnects.remove(device.address)?.let { ctx?.mainHandler?.removeCallbacks(it) } + val confirm = Runnable { + pendingConnects.remove(device.address) + if (isConnected(device)) { + announcedConnected += device.address + announce(device, connected = true) + } + } + pendingConnects[device.address] = confirm + ctx?.mainHandler?.postDelayed(confirm, CONNECT_SETTLE_MS) } } BluetoothDevice.ACTION_ACL_DISCONNECTED -> { + val unconfirmed = pendingConnects.remove(device.address) + if (unconfirmed != null) { + ctx?.mainHandler?.removeCallbacks(unconfirmed) + return + } pendingDisconnects.remove(device.address)?.let { ctx?.mainHandler?.removeCallbacks(it) } val confirm = Runnable { pendingDisconnects.remove(device.address) - announce(device, connected = false) + if (announcedConnected.remove(device.address)) announce(device, connected = false) } pendingDisconnects[device.address] = confirm ctx?.mainHandler?.postDelayed(confirm, DISCONNECT_SETTLE_MS) @@ -105,6 +121,7 @@ class DevicesPlugin : BaseIslandPlugin() { } context.registerReceiver(receiver, filter) registered = true + seedConnected() } catch (_: Exception) { } } @@ -113,6 +130,9 @@ class DevicesPlugin : BaseIslandPlugin() { ctx?.mainHandler?.removeCallbacks(clearEvent) pendingDisconnects.values.forEach { ctx?.mainHandler?.removeCallbacks(it) } pendingDisconnects.clear() + pendingConnects.values.forEach { ctx?.mainHandler?.removeCallbacks(it) } + pendingConnects.clear() + announcedConnected.clear() event = null batteryDevice = null if (registered) { @@ -126,9 +146,27 @@ class DevicesPlugin : BaseIslandPlugin() { override fun refresh() { ctx ?: return + seedConnected() refreshBattery() } + @SuppressLint("MissingPermission") + private fun seedConnected() { + if (!hasPermission()) return + val bonded = try { + (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter?.bondedDevices + } catch (_: SecurityException) { + null + }.orEmpty() + bonded.filter { isConnected(it) }.forEach { announcedConnected += it.address } + } + + private fun isConnected(device: BluetoothDevice): Boolean = try { + device.javaClass.getMethod("isConnected").invoke(device) as Boolean + } catch (_: Exception) { + true + } + @SuppressLint("MissingPermission") private fun refreshBattery() { if (ctx == null) return @@ -315,6 +353,7 @@ class DevicesPlugin : BaseIslandPlugin() { const val BATTERY_KEY = "devices.battery" private const val BATTERY_SETTLE_MS = 1500L private const val DISCONNECT_SETTLE_MS = 2000L + private const val CONNECT_SETTLE_MS = 3000L private const val ACTION_BATTERY_LEVEL_CHANGED = "android.bluetooth.device.action.BATTERY_LEVEL_CHANGED" private const val EXTRA_BATTERY_LEVEL = "android.bluetooth.device.extra.BATTERY_LEVEL" } diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/network/NetworkPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/network/NetworkPlugin.kt index 1ed2479c0f..2404b73568 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/network/NetworkPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/network/NetworkPlugin.kt @@ -43,6 +43,8 @@ class NetworkPlugin : BaseIslandPlugin() { private var ssid: String? = null private var airplane = false private var event: WifiEvent? = null + private var pendingLost: Runnable? = null + private var lostSsid: String? = null private val clearEvent = Runnable { event = null render() @@ -81,6 +83,9 @@ class NetworkPlugin : BaseIslandPlugin() { override fun onStop() { ctx?.mainHandler?.removeCallbacks(clearEvent) + pendingLost?.let { ctx?.mainHandler?.removeCallbacks(it) } + pendingLost = null + lostSsid = null try { connectivity.unregisterNetworkCallback(callback) } catch (_: Exception) { @@ -103,16 +108,38 @@ class NetworkPlugin : BaseIslandPlugin() { return } val name = ssidFrom(caps) ?: WifiUtil.getCurrentSsid(context) + val pending = pendingLost + if (pending != null) { + ctx?.mainHandler?.removeCallbacks(pending) + pendingLost = null + val previous = lostSsid + lostSsid = null + if (previous == null || name == null || previous == name) { + wifiNetwork = network + ssid = name ?: previous + return + } + announce(connected = false) + } + val sameNetwork = ssid != null && name == ssid wifiNetwork = network ssid = name - announce(connected = true) + if (!sameNetwork) announce(connected = true) } private fun onWifiLost(network: Network) { - if (ctx == null || network != wifiNetwork) return + val c = ctx ?: return + if (network != wifiNetwork) return wifiNetwork = null - announce(connected = false) - ssid = null + lostSsid = ssid + val confirm = Runnable { + pendingLost = null + lostSsid = null + announce(connected = false) + ssid = null + } + pendingLost = confirm + c.mainHandler.postDelayed(confirm, WIFI_LOST_SETTLE_MS) } private fun ssidFrom(caps: NetworkCapabilities): String? { @@ -180,5 +207,6 @@ class NetworkPlugin : BaseIslandPlugin() { companion object { const val WIFI_KEY = "network.wifi" const val AIRPLANE_KEY = "network.airplane" + private const val WIFI_LOST_SETTLE_MS = 4000L } } diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationExpanded.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationExpanded.kt index c4ad47e3d4..8a216a644a 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationExpanded.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationExpanded.kt @@ -127,6 +127,7 @@ fun NotificationExpanded( accent = glowColor, onSend = { text -> scope.setTextInput(false) + replyAction = null onReply(replying, text) }, onCancel = { @@ -181,6 +182,7 @@ private fun ReplyField( if (value.isEmpty()) return IslandHaptics.button(context) keyboard?.hide() + text = "" onSend(value) } Row( diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/notifications/NotificationsPlugin.kt index cc10d9ae43..d57c86f2c0 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() @@ -147,7 +153,15 @@ class NotificationsPlugin : BaseIslandPlugin() { private var autoExpanded = false private val timeoutRunnable = Runnable { onTimeout() } - private val catchUpRunnable = Runnable { clearAll() } + private val catchUpRunnable = Runnable { + if (ctx?.isContentSuppressed?.invoke() == true) catchUpPaused = true else clearAll() + } + + private val replied = HashMap() + private var catchUpPaused = false + private var copiedKey: String? = null + private var postedWhileCopied = false + private val copiedRunnable = Runnable { finishCopy() } private val listener = object : NotificationListener.NotificationAlertListener { override fun onNotificationAlertPosted(alert: ActiveNotificationAlert) { @@ -181,6 +195,21 @@ class NotificationsPlugin : BaseIslandPlugin() { if (!settings.isIslandShowNotificationsEnabled()) clearAll() else render() } + override fun onSuppressionChanged(suppressed: Boolean) { + val c = ctx ?: return + if (suppressed) { + if (catchingUp) { + c.mainHandler.removeCallbacks(catchUpRunnable) + catchUpPaused = true + } + } else if (catchUpPaused) { + catchUpPaused = false + if (alerts.isNotEmpty() && catchingUp && !settings.isIslandCatchUpInfinite()) { + c.mainHandler.postDelayed(catchUpRunnable, settings.getIslandCatchUpTimeoutMs()) + } + } + } + override fun onUserInteraction(focusedKey: String?) { if (alerts.isEmpty() || pendingPopUp) return if (focusedKey == ITEM_KEY) autoExpanded = false @@ -195,10 +224,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 +253,15 @@ class NotificationsPlugin : BaseIslandPlugin() { render() return } + val now = System.currentTimeMillis() + replied.values.removeAll { now - it > REPLY_REPOST_WINDOW_MS } + if (replied.containsKey(alert.key)) return + if (copiedKey != null) { + alerts.addLast(alert) + while (alerts.size > MAX_QUEUE) alerts.removeFirst() + postedWhileCopied = true + return + } c.mainHandler.removeCallbacks(catchUpRunnable) catchingUp = false if (queueEnabled() && showingHere()) { @@ -255,10 +305,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 +370,20 @@ 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 + catchUpPaused = false + copiedKey = null + postedWhileCopied = false cancelTimers() alerts.clear() seen.clear() @@ -358,6 +413,10 @@ class NotificationsPlugin : BaseIslandPlugin() { } private fun render() { + if (copiedKey != null) { + publish(copiedItem()) + return + } val alert = currentAlert() if (alert == null) { publish(null) @@ -435,6 +494,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 +573,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 +601,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 -> @@ -571,13 +743,21 @@ class NotificationsPlugin : BaseIslandPlugin() { val sent = canCarryText && NotificationListener.instance?.performNotificationAction(action, text) == true if (sent) { Toast.makeText(context, R.string.island_reply_sent, Toast.LENGTH_SHORT).show() - if (currentAlert()?.key == alert.key) popCurrent(reExpand = false) + dismissAfterReply(alert) } else { - popCurrent(reExpand = false) + dismissAfterReply(alert) context.performGlobalAction(AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS) } } + private fun dismissAfterReply(alert: ActiveNotificationAlert) { + val c = ctx ?: return + replied[alert.key] = System.currentTimeMillis() + val wasFocused = c.focusedKey() == ITEM_KEY + onRemoved(alert.key) + if (wasFocused) c.request(PluginRequest.Collapse(ITEM_KEY)) + } + private fun runAction(alert: ActiveNotificationAlert, action: NotificationActionItem) { HapticUtil.performStrongTickHaptic(context) if (action.isQuickReply) { @@ -592,6 +772,8 @@ class NotificationsPlugin : BaseIslandPlugin() { companion object { const val ITEM_KEY = "notifications" private const val MAX_QUEUE = 20 + private const val COPIED_MS = 1_500L + private const val REPLY_REPOST_WINDOW_MS = 10_000L private val GENERIC_TITLES = setOf( "you", "whatsapp", "messages", "telegram", "gmail", "instagram", "slack", "discord", "essentials", 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 0000000000..e24d719b37 --- /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 0000000000..f646209093 --- /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 0000000000..1a9a87923c --- /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 0000000000..10be9d1cb3 --- /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/service/IslandCoordinator.kt b/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt index cc4b611cc6..b805d557ea 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt @@ -690,6 +690,7 @@ class IslandCoordinator( cameraPresence = if (geo.hasCamera) 1f else 0f, landscape = isLandscape, bondEdge = !geo.hasCamera && settings.isIslandBondEdgeEnabled(), + balanceCompact = settings.isIslandBalanceCompactEnabled(), maxExpandedHeight = if (isLandscape) ((geo.screenHeight - geo.surfaceTop) / density - 12f).dp else Dp.Unspecified, outlineColor = if (settings.isIslandBorderOutlineEnabled()) { runCatching { Color(AndroidColor.parseColor(settings.getIslandBorderOutlineColor())) } @@ -715,13 +716,22 @@ class IslandCoordinator( windowHost.updateGeometry(geo) controller.lineStageEnabled = settings.isIslandLineStageEnabled() controller.maxCells = settings.getIslandMaxItems() * 2 + controller.balanceCompact = settings.isIslandBalanceCompactEnabled() + controller.secondaryBubbleEnabled = settings.isIslandSecondaryBubbleEnabled() controller.relayout() controller.expandedTimeoutMs = settings.getIslandExpandedTimeoutMs() applyPreviewRing() } + private var lastContentSuppressed = false + private fun applySuppression() { - controller.setSuppressed(isContentSuppressed || revealing || hiding) + val contentSuppressed = isContentSuppressed + if (contentSuppressed != lastContentSuppressed) { + lastContentSuppressed = contentSuppressed + plugins.forEach { it.onSuppressionChanged(contentSuppressed) } + } + controller.setSuppressed(contentSuppressed || revealing || hiding) windowHost.setInteractionsDisabled( isFullscreenApp && settings.isIslandKeepInFullscreenEnabled() && settings.isIslandDisableInteractionsInFullscreenEnabled(), ) @@ -834,6 +844,8 @@ class IslandCoordinator( SettingsRepository.KEY_ISLAND_HIDE_LANDSCAPE, SettingsRepository.KEY_ISLAND_LANDSCAPE_TOP_SPACING, SettingsRepository.KEY_ISLAND_BOND_EDGE, + SettingsRepository.KEY_ISLAND_BALANCE_COMPACT, + SettingsRepository.KEY_ISLAND_SECONDARY_BUBBLE, SettingsRepository.KEY_ISLAND_MAX_ITEMS, ) } diff --git a/app/src/main/java/com/sameerasw/essentials/island/state/CompactLayoutEngine.kt b/app/src/main/java/com/sameerasw/essentials/island/state/CompactLayoutEngine.kt index bd8a086808..5cf440153c 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/state/CompactLayoutEngine.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/state/CompactLayoutEngine.kt @@ -30,6 +30,7 @@ object CompactLayoutEngine { anchor: CameraAnchor = CameraAnchor.Center, maxCells: Int = MAX_CELLS, leftExtra: Int = 0, + balance: Boolean = false, ): CompactArrangement { val base = entries.filterNot { it.companionOnly } val companions = entries.filter { it.companionOnly } @@ -59,15 +60,46 @@ object CompactLayoutEngine { val twoCell = dynamic.filter { it.cellKeys.size == 2 } val oneCell = dynamic.filter { it.cellKeys.size == 1 } + val movable = mutableListOf() if (twoCell.isNotEmpty()) { after += twoCell[0].cellKeys.reversed() pinned.forEach { before += it.cellKeys } - twoCell.drop(1).forEach { before += it.cellKeys.reversed() } - oneCell.forEach { if (before.size + leftExtra <= after.size) before += it.cellKeys else after += it.cellKeys } + twoCell.drop(1).forEach { + before += it.cellKeys.reversed() + movable += it + } + oneCell.forEach { + if (before.size + leftExtra <= after.size) { + before += it.cellKeys + movable += it + } else { + after += it.cellKeys + } + } } else { pinned.getOrNull(0)?.let { before += it.cellKeys } pinned.getOrNull(1)?.let { after += it.cellKeys } - oneCell.forEach { if (after.size <= before.size + leftExtra) after += it.cellKeys else before += it.cellKeys } + oneCell.forEach { + if (after.size <= before.size + leftExtra) { + after += it.cellKeys + } else { + before += it.cellKeys + movable += it + } + } + } + + if (balance && leftExtra > 0) { + for (entry in movable.sortedByDescending { it.priority }) { + val size = entry.cellKeys.size + val left = before.size + leftExtra + val right = after.size + val gap = kotlin.math.abs(left - right) + val movedGap = kotlin.math.abs((left - size) - (right + size)) + if (movedGap > gap || (movedGap == gap && left <= right)) break + before.removeAll(entry.cellKeys.toSet()) + after += if (size == 2) entry.cellKeys.reversed() else entry.cellKeys + } } val solo = selected.singleOrNull() diff --git a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt index 437ea248d4..16a22a7cee 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt @@ -36,6 +36,8 @@ class IslandController( val state: StateFlow = _state.asStateFlow() var maxCells: Int = CompactLayoutEngine.MAX_CELLS + var balanceCompact: Boolean = true + var secondaryBubbleEnabled: Boolean = true private var feedbackActive = false fun setFeedbackActive(active: Boolean) { @@ -116,7 +118,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) @@ -271,7 +276,7 @@ class IslandController( if (peekKey != null && items[peekKey]?.line == null) cancelPeek() val bubbleOwner = items.values - .filter { it.sideBubble != null } + .filter { secondaryBubbleEnabled && it.sideBubble != null } .sortedBy { it.sideBubble!!.priority } .firstOrNull { candidate -> !candidate.sideBubble!!.whenOccupiedOnly || items.values.any { @@ -297,6 +302,7 @@ class IslandController( anchorProvider(), maxCells = maxCells, leftExtra = if (bubbleOwner != null) 1 else 0, + balance = balanceCompact, ) val focusedKey = expandedKey ?: peekKey val stage = when { diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt index 00a6d3d6a9..9b61d6580f 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/CompactTemplate.kt @@ -39,6 +39,7 @@ fun CompactTemplate( onCellTap: (String) -> Unit, onCellLongPress: (String) -> Unit, sideBubble: () -> Float = { 0f }, + bubbleExtent: () -> Int = { 0 }, onSlack: (Int) -> Unit = {}, ) { val cells = buildMap { @@ -123,6 +124,22 @@ fun CompactTemplate( val squareCell = spec.cellSize.roundToPx() val opticalInset = (height * OPTICAL_INSET_RATIO).toInt() fun edgeInset(outer: Placeable?) = if (outer != null && outer.width > squareCell) opticalInset else 0 + if (cameraSlot == 0) { + val cells = left + right + onSlack(0) + if (cells.isEmpty()) return@Layout layout(0, height) {} + val pad = ((height - squareCell) / 2).coerceAtLeast(0) + val startInset = edgeInset(cells.first()) + val endInset = edgeInset(cells.last()) + val width = pad * 2 + startInset + endInset + content(cells) + return@Layout layout(width, height) { + var x = pad + startInset + cells.forEach { + it.place(x, (height - it.height) / 2) + x += it.width + spacing + } + } + } if (spec.growDirection != 0) { val cells = left + right val inset = edgeInset(if (spec.growDirection > 0) cells.lastOrNull() else cells.firstOrNull()) @@ -145,9 +162,12 @@ fun CompactTemplate( val needRight = content(right) + rightInset + edge val t = if (hasCells) sideBubble().coerceIn(0f, 1f) else 0f val mirrored = maxOf(needLeft, needRight) - onSlack(needRight - needLeft) - val leftSide = (mirrored + (needLeft - mirrored) * t).roundToInt() - val rightSide = (mirrored + (needRight - mirrored) * t).roundToInt() + val extent = if (spec.balanceCompact) bubbleExtent().coerceAtLeast(0) else 0 + val balancedLeft = if (spec.balanceCompact) maxOf(needLeft, needRight - extent) else needLeft + val balancedRight = if (spec.balanceCompact) balancedLeft + extent else needRight + onSlack(if (spec.balanceCompact) extent else needRight - needLeft) + val leftSide = (mirrored + (balancedLeft - mirrored) * t).roundToInt() + val rightSide = (mirrored + (balancedRight - mirrored) * t).roundToInt() val width = leftSide + rightSide + cameraSlot layout(width, height) { var x = spacing + leftInset diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt index eab53a41d5..ec64f347db 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandLayoutSpec.kt @@ -26,6 +26,7 @@ data class IslandLayoutSpec( val maxExpandedHeight: Dp = Dp.Unspecified, val cameraPresence: Float = 1f, val bondEdge: Boolean = false, + val balanceCompact: Boolean = true, val landscape: Boolean = false, val cameraAnchor: CameraAnchor = CameraAnchor.Center, val outlineColor: Color? = null, @@ -41,7 +42,7 @@ data class IslandLayoutSpec( val queueInline: Boolean = false, ) { val compactHeight: Dp get() = cameraDiameter + verticalGap * 2 - val cameraSlotWidth: Dp get() = cameraDiameter * cameraPresence + cameraGap * 2 + val cameraSlotWidth: Dp get() = (cameraDiameter + cameraGap * 2) * cameraPresence val cellSize: Dp get() = cameraDiameter val cellSpacing: Dp get() = cameraGap diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt index 5743318395..9064024f38 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt @@ -1139,6 +1139,7 @@ fun IslandRoot( onCellTap = ::handleTap, onCellLongPress = ::handleLongPress, sideBubble = ::sideBubbleProgress, + bubbleExtent = { bubbleGapPx + sideWidthLive() }, onSlack = { if (current) compactSlack.intValue = it }, ) } @@ -1167,7 +1168,14 @@ fun IslandRoot( }, contentAlignment = layerAlign, ) { - CompactTemplate(state = state, spec = spec, onCellTap = {}, onCellLongPress = {}, sideBubble = ::sideBubbleProgress) + CompactTemplate( + state = state, + spec = spec, + onCellTap = {}, + onCellLongPress = {}, + sideBubble = ::sideBubbleProgress, + bubbleExtent = { bubbleGapPx + sideWidthLive() }, + ) } } } @@ -1262,6 +1270,7 @@ private fun StageContent( onCellTap: (String) -> Unit = {}, onCellLongPress: (String) -> Unit = {}, sideBubble: () -> Float = { 0f }, + bubbleExtent: () -> Int = { 0 }, onSlack: (Int) -> Unit = {}, ) { val a = if (interactive) actions else NoActions @@ -1279,6 +1288,7 @@ private fun StageContent( onCellTap = { if (interactive) onCellTap(it) }, onCellLongPress = { if (interactive) onCellLongPress(it) }, sideBubble = sideBubble, + bubbleExtent = bubbleExtent, onSlack = onSlack, ) } diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt index 2cc615815c..20d0f49479 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/LineTemplate.kt @@ -27,7 +27,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.font.Font @@ -90,7 +94,7 @@ fun LineTemplate( endSlot() } } else { - if (spec.growDirection > 0) Spacer(Modifier.width(spec.cameraSlotWidth - spec.cameraGap)) + if (spec.growDirection > 0) Spacer(Modifier.width((spec.cameraSlotWidth - spec.cameraGap).coerceAtLeast(0.dp))) Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, @@ -98,11 +102,15 @@ fun LineTemplate( ) { leadingSlot() if (line.showLeftIcon) icon() - LineText(line.start, TextAlign.Start, FontWeight.SemiBold, Modifier.weight(1f)) - LineText(line.end, TextAlign.End, FontWeight.Normal, Modifier.weight(1f)) + MarqueeText( + text = mergedLineText(line), + style = IslandTextStyles.line, + textAlign = TextAlign.Start, + modifier = Modifier.weight(1f), + ) endSlot() } - if (spec.growDirection < 0) Spacer(Modifier.width(spec.cameraSlotWidth - spec.cameraGap)) + if (spec.growDirection < 0) Spacer(Modifier.width((spec.cameraSlotWidth - spec.cameraGap).coerceAtLeast(0.dp))) } } } @@ -176,11 +184,11 @@ private fun CondensedLine(line: LineContent, spec: IslandLayoutSpec, startInset: verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spec.cellSpacing), ) { - if (spec.growDirection > 0) Spacer(Modifier.width(spec.cameraSlotWidth - spec.cameraGap)) + if (spec.growDirection > 0) Spacer(Modifier.width((spec.cameraSlotWidth - spec.cameraGap).coerceAtLeast(0.dp))) leadingSlot() icon() title(natural.coerceAtMost(maxText)) - if (spec.growDirection < 0) Spacer(Modifier.width(spec.cameraSlotWidth - spec.cameraGap)) + if (spec.growDirection < 0) Spacer(Modifier.width((spec.cameraSlotWidth - spec.cameraGap).coerceAtLeast(0.dp))) } } } @@ -203,6 +211,14 @@ private fun LeadingSlot(leading: LineLeading?, spec: IslandLayoutSpec) { } } +private fun mergedLineText(line: LineContent): AnnotatedString = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { append(line.start) } + if (line.end.isNotBlank()) { + if (line.start.isNotBlank()) append(" · ") + withStyle(SpanStyle(fontWeight = FontWeight.Normal)) { append(line.end) } + } +} + @Composable private fun LineText(text: String, align: TextAlign, weight: FontWeight, modifier: Modifier) { MarqueeText(text = text, style = IslandTextStyles.line.copy(fontWeight = weight), textAlign = align, modifier = modifier) diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/components/MarqueeText.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/components/MarqueeText.kt index 471c986700..75eee6484f 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/components/MarqueeText.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/components/MarqueeText.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign @@ -29,6 +30,17 @@ fun MarqueeText( fadeStart: Boolean = true, fadeEnd: Boolean = true, fadeWidth: Dp = 14.dp, +) = MarqueeText(AnnotatedString(text), style, modifier, textAlign, fadeStart, fadeEnd, fadeWidth) + +@Composable +fun MarqueeText( + text: AnnotatedString, + style: TextStyle, + modifier: Modifier = Modifier, + textAlign: TextAlign = TextAlign.Start, + fadeStart: Boolean = true, + fadeEnd: Boolean = true, + fadeWidth: Dp = 14.dp, ) { val measurer = rememberTextMeasurer() BoxWithConstraints(modifier) { 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 3764d5f6c2..b43c6c645b 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/services/handlers/DuoOverlayHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt index 7cd3a6eada..5c315b16d9 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt @@ -923,6 +923,8 @@ class DuoOverlayHandler( try { this.batteryPowerSaveColor = Color.parseColor(settingsRepository.getDuoBatteryPowerSaveColor()) } catch (_: Exception) {} + this.batteryLowLevel = settingsRepository.getDuoBatteryLowLevel() + this.batteryCriticalLevel = settingsRepository.getDuoBatteryCriticalLevel() this.isBatteryLowColorEnabled = settingsRepository.isDuoBatteryLowColorEnabled() try { this.batteryLowColor = Color.parseColor(settingsRepository.getDuoBatteryLowColor()) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt index 539a160bab..f41a435283 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/ConsciousGateActivity.kt @@ -29,6 +29,7 @@ import com.sameerasw.essentials.R import com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService import com.sameerasw.essentials.ui.features.consciousgate.ConsciousGatePauseScreen import com.sameerasw.essentials.ui.theme.EssentialsTheme +import com.sameerasw.essentials.utils.HapticUtil class ConsciousGateActivity : AppCompatActivity() { private var packageToGate: String? = null @@ -156,6 +157,16 @@ class ConsciousGateActivity : AppCompatActivity() { finishAndTransition() } + override fun onPause() { + HapticUtil.stopHoldHaptic(this) + super.onPause() + } + + override fun onStop() { + HapticUtil.stopHoldHaptic(this) + super.onStop() + } + @Deprecated("Deprecated in Java") override fun onBackPressed() { notifyClosedAndFinish() diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt index 062292c610..c55d1d67e3 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt @@ -383,6 +383,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Button remap" -> !isAccessibilityEnabled "Face unlock brightness" -> !isAccessibilityEnabled "Screenshots" -> !isAccessibilityEnabled || !viewModel.isStoragePermissionGranted.value + "Watch Wireless Debugging" -> android.os.Build.VERSION.SDK_INT >= 37 && !viewModel.isLocalNetworkGranted.value "Lock screen shortcuts" -> !isAccessibilityEnabled "Volume steps" -> !isAccessibilityEnabled "App volume" -> !isAccessibilityEnabled || !isShizukuPermissionGranted @@ -843,6 +844,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Button remap" -> !isAccessibilityEnabled "Face unlock brightness" -> !isAccessibilityEnabled "Screenshots" -> !isAccessibilityEnabled || !viewModel.isStoragePermissionGranted.value + "Watch Wireless Debugging" -> android.os.Build.VERSION.SDK_INT >= 37 && !viewModel.isLocalNetworkGranted.value "Lock screen shortcuts" -> !isAccessibilityEnabled "Volume steps" -> !isAccessibilityEnabled "App volume" -> !isAccessibilityEnabled || !isShizukuPermissionGranted diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/PixelSearchResultsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/PixelSearchResultsActivity.kt index aab20b7d89..90f2d54920 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/PixelSearchResultsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/PixelSearchResultsActivity.kt @@ -78,6 +78,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -219,9 +220,9 @@ fun PixelSearchResultsScreen( val keyboardController = LocalSoftwareKeyboardController.current val density = LocalDensity.current - var query by remember { mutableStateOf(initialQuery) } + var query by rememberSaveable { mutableStateOf(initialQuery) } val focusRequester = remember { FocusRequester() } - var selectedTab by remember { mutableStateOf(PixelSearchTab.ALL) } + var selectedTab by rememberSaveable { mutableStateOf(PixelSearchTab.ALL) } val isAllTab = selectedTab == PixelSearchTab.ALL var dragOffsetY by remember { mutableFloatStateOf(0f) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/EssentialsWatchfacePromoContent.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/EssentialsWatchfacePromoContent.kt index 24415d94a8..b5039255d5 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/EssentialsWatchfacePromoContent.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/EssentialsWatchfacePromoContent.kt @@ -4,157 +4,20 @@ * * Feature Module: UI Components * File: EssentialsWatchfacePromoContent.kt - * Description: Reusable modular component showcasing the Essentials Watchface app promotion and testing setup steps. + * Description: Reusable modular component showcasing the Essentials Watchface app promotion. */ package com.sameerasw.essentials.ui.components -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.sameerasw.essentials.R -import com.sameerasw.essentials.ui.core.cards.IconToggleItem -import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer -import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.viewmodels.WatchViewModel -@Composable -fun EssentialsWatchfacePromoContent( - modifier: Modifier = Modifier, - watchViewModel: WatchViewModel = viewModel(), -) { - val context = LocalContext.current - val uriHandler = LocalUriHandler.current - val view = LocalView.current - - val googleGroupUrl = "https://groups.google.com/u/1/g/madebysameerasw" - val betaTestingUrl = "https://play.google.com/apps/testing/com.sameerasw.essentials.watchface" - - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - RoundedCardContainer( - spacing = 2.dp, - cornerRadius = 24.dp, - ) { - // Step 1: Join Google Group - IconToggleItem( - iconRes = R.drawable.rounded_person_24, - title = stringResource(R.string.watchface_promo_step1_title), - description = stringResource(R.string.watchface_promo_step1_desc), - showToggle = false, - onClick = { - HapticUtil.performUIHaptic(view) - uriHandler.openUri(googleGroupUrl) - }, - trailingContent = { - Button( - onClick = { - HapticUtil.performUIHaptic(view) - uriHandler.openUri(googleGroupUrl) - }, - shape = MaterialTheme.shapes.extraLarge, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - ) { - Text( - text = stringResource(R.string.watchface_promo_step1_action), - style = MaterialTheme.typography.labelMedium, - ) - } - }, - ) - - // Step 2: Join Beta Program - IconToggleItem( - iconRes = R.drawable.rounded_science_24, - title = stringResource(R.string.watchface_promo_step2_title), - description = stringResource(R.string.watchface_promo_step2_desc), - showToggle = false, - onClick = { - HapticUtil.performUIHaptic(view) - uriHandler.openUri(betaTestingUrl) - }, - trailingContent = { - Button( - onClick = { - HapticUtil.performUIHaptic(view) - uriHandler.openUri(betaTestingUrl) - }, - shape = MaterialTheme.shapes.extraLarge, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - ) { - Text( - text = stringResource(R.string.watchface_promo_step2_action), - style = MaterialTheme.typography.labelMedium, - ) - } - }, - ) - - // Step 3: Install on Watch - IconToggleItem( - iconRes = R.drawable.rounded_watch_24, - title = stringResource(R.string.watchface_promo_step3_title), - description = stringResource(R.string.watchface_promo_step3_desc), - showToggle = false, - onClick = { - HapticUtil.performUIHaptic(view) - watchViewModel.openWatchfacePlayStoreOnWatch(context) - }, - trailingContent = { - Button( - onClick = { - HapticUtil.performUIHaptic(view) - watchViewModel.openWatchfacePlayStoreOnWatch(context) - }, - shape = MaterialTheme.shapes.extraLarge, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ), - ) { - Icon( - painter = painterResource(id = R.drawable.rounded_open_in_new_24), - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - androidx.compose.foundation.layout.Spacer(modifier = Modifier.size(6.dp)) - Text( - text = stringResource(R.string.watchface_promo_step3_action), - style = MaterialTheme.typography.labelMedium, - ) - } - }, - ) - } - } -} - -// Play Store version, to replace the internal testing steps above once the watchface is approved: -// (the step 3 title string needs its "3. " prefix dropped again) -/* @Composable fun EssentialsWatchfacePromoContent( modifier: Modifier = Modifier, @@ -172,4 +35,3 @@ fun EssentialsWatchfacePromoContent( modifier = modifier.fillMaxWidth(), ) } -*/ diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt index a7bc353071..32cf8972d7 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt @@ -76,6 +76,28 @@ fun WhatsNewCustomContent( verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth(), ) { + WhatsNewSectionCard( + title = stringResource(R.string.whats_new_watchface_title), + summary = stringResource(R.string.whats_new_watchface_summary), + expanded = expandedSection == 1, + onToggle = { toggle(1) }, + icon = { SectionIcon(R.drawable.rounded_watch_24) }, + ) { + Text( + text = stringResource(R.string.whats_new_watchface_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + EssentialsWatchfacePromoContent() + } + + Text( + text = stringResource(R.string.whats_new_previous_updates), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp, top = 8.dp), + ) + WhatsNewSectionCard( title = stringResource(R.string.whats_new_overcast_card_title), summary = stringResource(R.string.whats_new_overcast_section_title), @@ -118,23 +140,6 @@ fun WhatsNewCustomContent( ) } - /* - WhatsNewSectionCard( - title = stringResource(R.string.whats_new_watchface_title), - summary = stringResource(R.string.whats_new_watchface_summary), - expanded = expandedSection == 1, - onToggle = { toggle(1) }, - icon = { SectionIcon(R.drawable.rounded_watch_24) }, - ) { - Text( - text = stringResource(R.string.whats_new_watchface_desc), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - EssentialsWatchfacePromoContent() - } - */ - WhatsNewSectionCard( title = stringResource(R.string.setting_hidden_debugging_title), summary = stringResource(R.string.whats_new_hidden_debugging_summary), diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt index afddc32544..ff604101f3 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt @@ -1363,7 +1363,7 @@ fun SetupFeatures( viewModel = viewModel, ) } - } else if (isFocused && searchQuery.isNotEmpty()) { + } else if (searchQuery.isNotEmpty()) { if (!isSearchingViewModel && searchResults.isEmpty() && matchingTiles.isEmpty() && matchingPermissions.isEmpty() && matchingFrozenApps.isEmpty()) { item { Column( diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/audio/SoundModeTileSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/audio/SoundModeTileSettingsUI.kt index ab20c76707..7c13fee0e8 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/audio/SoundModeTileSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/audio/SoundModeTileSettingsUI.kt @@ -13,6 +13,7 @@ import android.content.Intent import android.net.Uri import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -178,6 +179,7 @@ fun SoundModeTileSettingsUI( .scrollMotionBlur(lazyListState, enabled = isMotionBlurEnabled) .padding(horizontal = 16.dp), state = lazyListState, + contentPadding = PaddingValues(bottom = 160.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { items(activeModes.size, key = { activeModes[it] }) { index -> diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt index 7a7c80302d..56bb94ef62 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/consciousgate/ConsciousGatePauseScreen.kt @@ -59,6 +59,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.sameerasw.essentials.R import com.sameerasw.essentials.utils.AppUtil import com.sameerasw.essentials.utils.HapticUtil @@ -102,11 +105,14 @@ fun ConsciousGatePauseScreen( var isHolding by remember { mutableStateOf(false) } val view = LocalView.current + val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(isHolding) { - while (isActive) { - delay(1000L) - if (!isHolding && !isActionInvoked) { - HapticUtil.performLightHaptic(view) + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (isActive) { + delay(1000L) + if (!isHolding && !isActionInvoked) { + HapticUtil.performLightHaptic(view) + } } } } @@ -301,10 +307,13 @@ internal fun HoldToContinueButton( } } - tryAwaitRelease() - onHoldingChange(false) - animJob.cancel() - HapticUtil.stopHoldHaptic(context) + try { + tryAwaitRelease() + } finally { + onHoldingChange(false) + animJob.cancel() + HapticUtil.stopHoldHaptic(context) + } if (completed || holdProgress.value >= 0.99f) { HapticUtil.performCustomHaptic(view, 1.0f) 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 c096fb5af1..ad259bf9ef 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSubSettingsUI.kt @@ -605,6 +605,28 @@ fun IslandBehaviorSettingsUI( modifier = Modifier.highlight(highlightSetting == "island_line_stage_enabled"), ) + IconToggleItem( + iconRes = R.drawable.rounded_notifications_unread_24, + title = stringResource(R.string.island_secondary_bubble_title), + isChecked = viewModel.isIslandSecondaryBubble.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setIslandSecondaryBubble(checked) + }, + modifier = Modifier.highlight(highlightSetting == "island_secondary_bubble"), + ) + + IconToggleItem( + iconRes = R.drawable.rounded_format_align_center_24, + title = stringResource(R.string.island_balance_compact_title), + isChecked = viewModel.isIslandBalanceCompact.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setIslandBalanceCompact(checked) + }, + modifier = Modifier.highlight(highlightSetting == "island_balance_compact"), + ) + Column( modifier = Modifier .fillMaxWidth() @@ -883,6 +905,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 && @@ -1136,6 +1159,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( @@ -1198,6 +1305,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/DuoBatteryOptionsBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/DuoBatteryOptionsBottomSheet.kt index f986003fc0..fd4902f09f 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/DuoBatteryOptionsBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/DuoBatteryOptionsBottomSheet.kt @@ -27,10 +27,12 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R +import com.sameerasw.essentials.ui.components.sliders.ConfigSliderItem import com.sameerasw.essentials.ui.core.cards.IconToggleItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.pickers.ColorSwatchPicker import com.sameerasw.essentials.ui.core.sheets.EssentialsBottomSheet +import com.sameerasw.essentials.utils.DuoBatteryLevels import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.viewmodels.MainViewModel @@ -38,10 +40,10 @@ import com.sameerasw.essentials.viewmodels.MainViewModel fun BatteryColorOptions( viewModel: MainViewModel, showIdleColor: Boolean = false, + showLevels: Boolean = false, ) { val view = LocalView.current Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - // Low Battery (< 20%) RoundedCardContainer( spacing = 2.dp, cornerRadius = 24.dp, @@ -60,16 +62,25 @@ fun BatteryColorOptions( enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { - ColorSwatchPicker( - selectedColorHex = viewModel.duoBatteryLowColor.value, - onColorSelected = { hex -> - viewModel.setDuoBatteryLowColor(hex) - }, - ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (showLevels) { + LevelSlider( + title = stringResource(R.string.duo_battery_low_level_title), + level = viewModel.duoBatteryLowLevel.intValue, + range = (DuoBatteryLevels.MIN + 1)..DuoBatteryLevels.MAX, + onLevelChange = viewModel::setDuoBatteryLowLevel, + ) + } + ColorSwatchPicker( + selectedColorHex = viewModel.duoBatteryLowColor.value, + onColorSelected = { hex -> + viewModel.setDuoBatteryLowColor(hex) + }, + ) + } } } - // Critically Low Battery (< 10%) RoundedCardContainer( spacing = 2.dp, cornerRadius = 24.dp, @@ -88,12 +99,22 @@ fun BatteryColorOptions( enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { - ColorSwatchPicker( - selectedColorHex = viewModel.duoBatteryCriticalColor.value, - onColorSelected = { hex -> - viewModel.setDuoBatteryCriticalColor(hex) - }, - ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (showLevels) { + LevelSlider( + title = stringResource(R.string.duo_battery_critical_level_title), + level = viewModel.duoBatteryCriticalLevel.intValue, + range = DuoBatteryLevels.MIN..(DuoBatteryLevels.MAX - 1), + onLevelChange = viewModel::setDuoBatteryCriticalLevel, + ) + } + ColorSwatchPicker( + selectedColorHex = viewModel.duoBatteryCriticalColor.value, + onColorSelected = { hex -> + viewModel.setDuoBatteryCriticalColor(hex) + }, + ) + } } } @@ -185,6 +206,28 @@ fun BatteryColorOptions( } } +@Composable +private fun LevelSlider( + title: String, + level: Int, + range: IntRange, + onLevelChange: (Int) -> Unit, +) { + val view = LocalView.current + ConfigSliderItem( + title = title, + value = level.toFloat(), + onValueChange = { + HapticUtil.performUIHaptic(view) + onLevelChange(it.toInt().coerceIn(range)) + }, + valueRange = range.first.toFloat()..range.last.toFloat(), + increment = 1f, + iconRes = R.drawable.rounded_percent_24, + valueFormatter = { "${it.toInt()}%" }, + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun DuoBatteryOptionsBottomSheet( @@ -209,7 +252,7 @@ fun DuoBatteryOptionsBottomSheet( modifier = Modifier.padding(start = 8.dp, bottom = 4.dp), ) - BatteryColorOptions(viewModel) + BatteryColorOptions(viewModel, showLevels = true) } } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/watch/WatchWirelessDebuggingSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/watch/WatchWirelessDebuggingSettingsUI.kt index 1a60fd3b3e..6503bd1180 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/watch/WatchWirelessDebuggingSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/watch/WatchWirelessDebuggingSettingsUI.kt @@ -12,6 +12,7 @@ package com.sameerasw.essentials.ui.features.watch import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.os.Build import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -27,6 +28,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -38,11 +40,13 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect import com.google.android.gms.wearable.Wearable import com.sameerasw.essentials.R import com.sameerasw.essentials.ui.core.cards.IconToggleItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.utils.HapticUtil +import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.viewmodels.MainViewModel @Composable @@ -104,6 +108,22 @@ fun WatchWirelessDebuggingSettingsUI( } } + LifecycleResumeEffect(Unit) { + viewModel.check(context) + onPauseOrDispose { } + } + + LaunchedEffect(Unit) { + if (Build.VERSION.SDK_INT >= 37 && + !PermissionUtils.hasLocalNetworkPermission(context) && + !prefs.getBoolean("local_network_requested", false) + ) { + PermissionUtils.requestLocalNetwork(context) + } + } + + val needsLocalNetwork = Build.VERSION.SDK_INT >= 37 && !viewModel.isLocalNetworkGranted.value + val isSsidAvailable = watchSsid.isNotBlank() val isIpAvailable = watchIp.isNotBlank() val isPortAvailable = watchPort > 0 @@ -115,6 +135,20 @@ fun WatchWirelessDebuggingSettingsUI( .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + if (needsLocalNetwork) { + RoundedCardContainer { + IconToggleItem( + iconRes = R.drawable.rounded_lan_24, + title = stringResource(R.string.perm_local_network_title), + description = stringResource(R.string.perm_local_network_desc), + showToggle = false, + onClick = { + HapticUtil.performUIHaptic(view) + PermissionUtils.requestLocalNetwork(context) + }, + ) + } + } RoundedCardContainer { IconToggleItem( iconRes = R.drawable.rounded_adb_24, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/watch/sheets/WatchInstallHelpBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/watch/sheets/WatchInstallHelpBottomSheet.kt index e9690e8c39..75db85eedc 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/watch/sheets/WatchInstallHelpBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/watch/sheets/WatchInstallHelpBottomSheet.kt @@ -183,7 +183,7 @@ fun WatchInstallHelpBottomSheet( var isWatchfaceSectionExpanded by remember { mutableStateOf(false) } CategoryExpandableSection( title = stringResource(R.string.watch_watchface_title), - itemCount = 3, + itemCount = 1, isExpanded = isWatchfaceSectionExpanded, onToggleExpand = { isWatchfaceSectionExpanded = !isWatchfaceSectionExpanded }, ) { diff --git a/app/src/main/java/com/sameerasw/essentials/utils/DuoBatteryLevels.kt b/app/src/main/java/com/sameerasw/essentials/utils/DuoBatteryLevels.kt new file mode 100644 index 0000000000..0c7269722a --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/DuoBatteryLevels.kt @@ -0,0 +1,24 @@ +package com.sameerasw.essentials.utils + +object DuoBatteryLevels { + const val DEFAULT_LOW = 20 + const val DEFAULT_CRITICAL = 10 + const val MIN = 1 + const val MAX = 99 + + fun withLow( + low: Int, + critical: Int, + ): Pair { + val newLow = low.coerceIn(MIN + 1, MAX) + return newLow to critical.coerceIn(MIN, newLow - 1) + } + + fun withCritical( + critical: Int, + low: Int, + ): Pair { + val newCritical = critical.coerceIn(MIN, MAX - 1) + return low.coerceIn(newCritical + 1, MAX) to newCritical + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt b/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt index 5d75c48b40..52562c277e 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt @@ -201,8 +201,8 @@ class DuoOverlayView(context: Context) : View(context) { fun isBatteryColoredInstanceActive(): Boolean { if (isCharging && isBatteryChargingColorEnabled) return true if (isPowerSaveMode && isBatteryPowerSaveColorEnabled) return true - if (batteryLevel <= 10 && isBatteryCriticalColorEnabled) return true - if (batteryLevel <= 20 && isBatteryLowColorEnabled) return true + if (batteryLevel <= batteryCriticalLevel && isBatteryCriticalColorEnabled) return true + if (batteryLevel <= batteryLowLevel && isBatteryLowColorEnabled) return true return false } @@ -363,6 +363,24 @@ class DuoOverlayView(context: Context) : View(context) { } } + var batteryLowLevel: Int = DuoBatteryLevels.DEFAULT_LOW + set(value) { + if (field != value) { + field = value + animateThemeChange() + updateBatteryPercentageVisibility() + } + } + + var batteryCriticalLevel: Int = DuoBatteryLevels.DEFAULT_CRITICAL + set(value) { + if (field != value) { + field = value + animateThemeChange() + updateBatteryPercentageVisibility() + } + } + var isBatteryLowColorEnabled: Boolean = true set(value) { if (field != value) { @@ -1119,13 +1137,13 @@ class DuoOverlayView(context: Context) : View(context) { val dimTrack = Color.argb(50, Color.red(saveAccent), Color.green(saveAccent), Color.blue(saveAccent)) return Triple(dimTrack, dimProgress, dimProgress) } - if ((showBattery || showBatteryPercentage) && batteryLevel <= 10 && isBatteryCriticalColorEnabled) { + if ((showBattery || showBatteryPercentage) && batteryLevel <= batteryCriticalLevel && isBatteryCriticalColorEnabled) { val critAccent = getThemeAdjustedColor(batteryCriticalColor) val dimProgress = Color.argb(160, Color.red(critAccent), Color.green(critAccent), Color.blue(critAccent)) val dimTrack = Color.argb(50, Color.red(critAccent), Color.green(critAccent), Color.blue(critAccent)) return Triple(dimTrack, dimProgress, dimProgress) } - if ((showBattery || showBatteryPercentage) && batteryLevel <= 20 && isBatteryLowColorEnabled) { + if ((showBattery || showBatteryPercentage) && batteryLevel <= batteryLowLevel && isBatteryLowColorEnabled) { val lowAccent = getThemeAdjustedColor(batteryLowColor) val dimProgress = Color.argb(160, Color.red(lowAccent), Color.green(lowAccent), Color.blue(lowAccent)) val dimTrack = Color.argb(50, Color.red(lowAccent), Color.green(lowAccent), Color.blue(lowAccent)) @@ -1173,14 +1191,14 @@ class DuoOverlayView(context: Context) : View(context) { return Triple(track, saveAccent, saveAccent) } - if ((showBattery || showBatteryPercentage) && batteryLevel <= 10 && isBatteryCriticalColorEnabled) { + if ((showBattery || showBatteryPercentage) && batteryLevel <= batteryCriticalLevel && isBatteryCriticalColorEnabled) { val critAccent = getThemeAdjustedColor(batteryCriticalColor) val trackAlpha = if (isDarkTheme) 90 else 110 val track = Color.argb(trackAlpha, Color.red(critAccent), Color.green(critAccent), Color.blue(critAccent)) return Triple(track, critAccent, critAccent) } - if ((showBattery || showBatteryPercentage) && batteryLevel <= 20 && isBatteryLowColorEnabled) { + if ((showBattery || showBatteryPercentage) && batteryLevel <= batteryLowLevel && isBatteryLowColorEnabled) { val lowAccent = getThemeAdjustedColor(batteryLowColor) val trackAlpha = if (isDarkTheme) 90 else 110 val track = Color.argb(trackAlpha, Color.red(lowAccent), Color.green(lowAccent), Color.blue(lowAccent)) diff --git a/app/src/main/java/com/sameerasw/essentials/utils/PermissionGrantUtil.kt b/app/src/main/java/com/sameerasw/essentials/utils/PermissionGrantUtil.kt index b7fb3afa2a..231726804d 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/PermissionUtils.kt b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt index 91af2ac7af..32c1cc30b9 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt @@ -150,6 +150,27 @@ object PermissionUtils { false } + const val LOCAL_NETWORK_PERMISSION = "android.permission.ACCESS_LOCAL_NETWORK" + + fun hasLocalNetworkPermission(context: Context): Boolean = + Build.VERSION.SDK_INT < 37 || + androidx.core.content.ContextCompat.checkSelfPermission(context, LOCAL_NETWORK_PERMISSION) == + android.content.pm.PackageManager.PERMISSION_GRANTED + + fun requestLocalNetwork( + context: Context, + activity: android.app.Activity? = null, + ) { + val target = activity ?: generateSequence(context) { (it as? android.content.ContextWrapper)?.baseContext } + .filterIsInstance() + .firstOrNull() ?: return + context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + .edit().putBoolean("local_network_requested", true).apply() + androidx.core.app.ActivityCompat.requestPermissions(target, arrayOf(LOCAL_NETWORK_PERMISSION), LOCAL_NETWORK_REQUEST_CODE) + } + + private const val LOCAL_NETWORK_REQUEST_CODE = 137 + fun hasBubblePermission(context: Context): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return false val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? android.app.NotificationManager ?: return false diff --git a/app/src/main/java/com/sameerasw/essentials/utils/call/CallNotificationParser.kt b/app/src/main/java/com/sameerasw/essentials/utils/call/CallNotificationParser.kt index 0b6a7ccfaa..6ff391c89b 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/call/CallNotificationParser.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/call/CallNotificationParser.kt @@ -33,6 +33,7 @@ data class NotificationCall( val contentIntent: PendingIntent?, val postedAt: Long, val extraActions: List = emptyList(), + val allActions: List = emptyList(), ) object CallNotificationParser { @@ -134,6 +135,7 @@ object CallNotificationParser { contentIntent = n.contentIntent, postedAt = sbn.postTime, extraActions = extraActions, + allActions = classified.filter { it.title.isNotBlank() }, ) } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/call/CallStateRepository.kt b/app/src/main/java/com/sameerasw/essentials/utils/call/CallStateRepository.kt index 817bc7e6cb..673976244b 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/call/CallStateRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/call/CallStateRepository.kt @@ -27,6 +27,7 @@ data class CallSnapshot( val contentIntent: PendingIntent? = null, val fromTelephony: Boolean = false, val extraActions: List = emptyList(), + val allActions: List = emptyList(), ) @@ -140,6 +141,7 @@ object CallStateRepository { contentIntent = call.contentIntent, fromTelephony = phone != null, extraActions = call.extraActions, + allActions = call.allActions, ) } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/chronometer/ChronometerRepository.kt b/app/src/main/java/com/sameerasw/essentials/utils/chronometer/ChronometerRepository.kt index 5d5f1af9d0..4a9c175b43 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/chronometer/ChronometerRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/chronometer/ChronometerRepository.kt @@ -96,15 +96,19 @@ object ChronometerRepository { if (n.extras.containsKey(Notification.EXTRA_MEDIA_SESSION)) return false val chronometer = n.extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER) || timeMetric(n.extras) != null - return (chronometer && sbn.isOngoing) || synchronized(this) { tracked.containsKey(sbn.key) } + return (chronometer && sbn.isOngoing && !hasProgress(n.extras)) || synchronized(this) { tracked.containsKey(sbn.key) } } + private fun hasProgress(extras: Bundle): Boolean = + extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0) > 0 || + extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE, false) + @Synchronized fun onPosted(context: Context, sbn: StatusBarNotification) { val n = sbn.notification val extras = n.extras val previous = tracked[sbn.key] - if (!sbn.isOngoing) { + if (!sbn.isOngoing || hasProgress(extras)) { remove(sbn.key) return } 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 d595d9c2eb..1771a5b993 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,48 @@ object PermissionUIHelper { ) } + AppPermission.LOCAL_NETWORK -> + PermissionItem( + iconRes = permission.iconRes, + title = permission.titleRes, + description = R.string.perm_local_network_desc, + dependentFeatures = PermissionRegistry.getFeatures(permission), + actionLabel = if (viewModel.isLocalNetworkGranted.value) R.string.perm_action_granted else R.string.perm_action_grant, + action = { PermissionUtils.requestLocalNetwork(context, activity) }, + isGranted = viewModel.isLocalNetworkGranted.value, + ) + + AppPermission.SENSITIVE_NOTIFICATIONS -> + PermissionItem( + iconRes = permission.iconRes, + 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 f4d353b747..d222d71806 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -84,6 +84,8 @@ import com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService import com.sameerasw.essentials.utils.AppIconUtil import com.sameerasw.essentials.utils.AppUtil import com.sameerasw.essentials.utils.DeviceUtils +import com.sameerasw.essentials.utils.DuoBatteryLevels +import com.sameerasw.essentials.utils.PermissionGrantUtil import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.utils.RefreshRateUtils import com.sameerasw.essentials.utils.RootUtils @@ -188,6 +190,8 @@ class MainViewModel : ViewModel() { val duoBatteryPowerSaveColor = mutableStateOf("#FF9800") val isDuoBatteryLowColorEnabled = mutableStateOf(true) val duoBatteryLowColor = mutableStateOf("#FFEB3B") + val duoBatteryLowLevel = mutableIntStateOf(DuoBatteryLevels.DEFAULT_LOW) + val duoBatteryCriticalLevel = mutableIntStateOf(DuoBatteryLevels.DEFAULT_CRITICAL) val isIslandBatteryIdleColorEnabled = mutableStateOf(false) val islandBatteryIdleColor = mutableStateOf("#FFFFFF") val isDuoBatteryCriticalColorEnabled = mutableStateOf(true) @@ -230,6 +234,8 @@ class MainViewModel : ViewModel() { val isIslandKeepInFullscreen = mutableStateOf(false) val isIslandDisableInteractionsInFullscreen = mutableStateOf(false) val isIslandBondEdge = mutableStateOf(false) + val isIslandBalanceCompact = mutableStateOf(true) + val isIslandSecondaryBubble = mutableStateOf(true) val islandMaxItems = mutableStateOf(2) val isIslandAlwaysGestures = mutableStateOf(false) val islandLandscapeTopSpacing = mutableFloatStateOf(0f) @@ -285,6 +291,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) @@ -598,6 +606,8 @@ class MainViewModel : ViewModel() { val isWriteSettingsEnabled = mutableStateOf(false) val isCalendarPermissionGranted = mutableStateOf(false) val isBubblePermissionGranted = mutableStateOf(false) + val isSensitiveNotificationsGranted = mutableStateOf(true) + val isLocalNetworkGranted = mutableStateOf(true) val isUserDictionaryEnabled = mutableStateOf(false) val userDictionaryWords = mutableStateOf>(emptyMap()) val isUserDictionarySheetVisible = mutableStateOf(false) @@ -834,6 +844,12 @@ class MainViewModel : ViewModel() { SettingsRepository.KEY_DUO_BATTERY_CRITICAL_COLOR -> duoBatteryCriticalColor.value = settingsRepository.getDuoBatteryCriticalColor() + SettingsRepository.KEY_DUO_BATTERY_LOW_LEVEL -> + duoBatteryLowLevel.intValue = settingsRepository.getDuoBatteryLowLevel() + + SettingsRepository.KEY_DUO_BATTERY_CRITICAL_LEVEL -> + duoBatteryCriticalLevel.intValue = settingsRepository.getDuoBatteryCriticalLevel() + SettingsRepository.KEY_DUO_SHOW_NETWORKS -> isDuoShowNetworks.value = settingsRepository.isDuoShowNetworksEnabled() @@ -988,6 +1004,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() @@ -1850,6 +1872,8 @@ class MainViewModel : ViewModel() { isAccessibilityEnabled.value = PermissionUtils.isAccessibilityServiceEnabled(context) isWriteSecureSettingsEnabled.value = PermissionUtils.canWriteSecureSettings(context) isBubblePermissionGranted.value = PermissionUtils.hasBubblePermission(context) + isSensitiveNotificationsGranted.value = PermissionGrantUtil.hasSensitiveNotifications(context) + isLocalNetworkGranted.value = PermissionUtils.hasLocalNetworkPermission(context) isShizukuAvailable.value = ShizukuUtils.isShizukuAvailable() isShizukuPermissionGranted.value = ShizukuUtils.hasPermission() if (cachedIsUpdateAvailable) { @@ -2417,6 +2441,8 @@ class MainViewModel : ViewModel() { islandBatteryIdleColor.value = settingsRepository.getIslandBatteryIdleColor() isDuoBatteryCriticalColorEnabled.value = settingsRepository.isDuoBatteryCriticalColorEnabled() duoBatteryCriticalColor.value = settingsRepository.getDuoBatteryCriticalColor() + duoBatteryLowLevel.intValue = settingsRepository.getDuoBatteryLowLevel() + duoBatteryCriticalLevel.intValue = settingsRepository.getDuoBatteryCriticalLevel() isDuoShowNetworks.value = settingsRepository.isDuoShowNetworksEnabled() isDuoDifferentiateWifi.value = settingsRepository.isDuoDifferentiateWifiEnabled() isDuoShowTime.value = settingsRepository.isDuoShowTimeEnabled() @@ -2452,6 +2478,8 @@ class MainViewModel : ViewModel() { isIslandKeepInFullscreen.value = settingsRepository.isIslandKeepInFullscreenEnabled() isIslandDisableInteractionsInFullscreen.value = settingsRepository.isIslandDisableInteractionsInFullscreenEnabled() isIslandBondEdge.value = settingsRepository.isIslandBondEdgeEnabled() + isIslandBalanceCompact.value = settingsRepository.isIslandBalanceCompactEnabled() + isIslandSecondaryBubble.value = settingsRepository.isIslandSecondaryBubbleEnabled() islandMaxItems.value = settingsRepository.getIslandMaxItems() isIslandAlwaysGestures.value = settingsRepository.isIslandAlwaysGesturesEnabled() islandLandscapeTopSpacing.floatValue = settingsRepository.getIslandLandscapeTopSpacing() @@ -2520,6 +2548,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() @@ -5495,6 +5525,22 @@ class MainViewModel : ViewModel() { settingsRepository.setDuoBatteryCriticalColor(colorHex) } + fun setDuoBatteryLowLevel(level: Int) { + val (low, critical) = DuoBatteryLevels.withLow(level, duoBatteryCriticalLevel.intValue) + applyDuoBatteryLevels(low, critical) + } + + fun setDuoBatteryCriticalLevel(level: Int) { + val (low, critical) = DuoBatteryLevels.withCritical(level, duoBatteryLowLevel.intValue) + applyDuoBatteryLevels(low, critical) + } + + private fun applyDuoBatteryLevels(low: Int, critical: Int) { + duoBatteryLowLevel.intValue = low + duoBatteryCriticalLevel.intValue = critical + settingsRepository.setDuoBatteryLevels(low, critical) + } + fun setDuoShowNetworks(enabled: Boolean) { isDuoShowNetworks.value = enabled settingsRepository.setDuoShowNetworksEnabled(enabled) @@ -5823,6 +5869,16 @@ class MainViewModel : ViewModel() { settingsRepository.setIslandMaxItems(value) } + fun setIslandSecondaryBubble(enabled: Boolean) { + isIslandSecondaryBubble.value = enabled + settingsRepository.setIslandSecondaryBubbleEnabled(enabled) + } + + fun setIslandBalanceCompact(enabled: Boolean) { + isIslandBalanceCompact.value = enabled + settingsRepository.setIslandBalanceCompactEnabled(enabled) + } + fun setIslandBondEdge(enabled: Boolean) { isIslandBondEdge.value = enabled settingsRepository.setIslandBondEdgeEnabled(enabled) @@ -6016,6 +6072,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-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 24a49f1199..f40ec053d3 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1938,11 +1938,7 @@ Cần có quyền truy cập trợ năng để hiển thị lớp phủ và cầ Tạm dừng trước khi mở các ứng dụng đã chọn Không hỗ trợ Cũ - 1. Tham gia nhóm Google - Hãy tham gia nhóm thử nghiệm bằng tài khoản Gmail được liên kết với đồng hồ của bạn - 2. Tham gia Chương trình Beta - Đăng ký tham gia chương trình thử nghiệm nội bộ trên Google Play - 3. Cài đặt trên đồng hồ + Cài đặt trên đồng hồ Mở trực tiếp mục Play Store trên đồng hồ của bạn để cài đặt Vị trí Hình ảnh diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9039340bc8..6a29093bce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2981,7 +2981,12 @@ More Show granted Hide granted + Local network access + Android 17 blocks apps from talking to devices on your Wi-Fi network unless you allow it. Essentials needs this to work with your watch over the local network. Floating Bubbles + Read sensitive notifications + Android hides one-time codes from notification listeners. Essentials needs this access to show codes on the island. + 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 @@ -3020,8 +3025,10 @@ Idle color Auto charging speed Battery saver - Low battery (<20%) - Critically low battery (<10%) + Low battery + Low battery level + Critically low battery + Critical battery level Configuration Select apps to skip Networks @@ -3144,6 +3151,8 @@ Keep on landscape Landscape top spacing Bond with notch when inactive + Secondary bubble + Visually balance compact island Max items to show Always available gestures Keep the gesture area available even while island is hidden @@ -3439,6 +3448,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 @@ -3472,20 +3497,16 @@ Expand settings Collapse status bar - + New: Essentials Watchface Install the Essentials watchface on your Wear OS watch straight from Google Play. Now on Google Play for Wear OS - 1. Join Google Group - Join the testing group using the Gmail account associated with your watch - Join Google Group - 2. Join Beta Program - Opt-in to the internal testing program on Google Play - Join Beta Program - 3. Install on Watch + Install on Watch Open the Play Store listing directly on your watch to install Open on Watch + From previous updates + Weather is Now Separate Weather has been separated into my brand new app, Overcast. Install to continue having weather forecasts and effects in Island brief. diff --git a/app/src/test/java/com/sameerasw/essentials/island/CompactLayoutEngineTest.kt b/app/src/test/java/com/sameerasw/essentials/island/CompactLayoutEngineTest.kt index b49e489bf4..0f51f21515 100644 --- a/app/src/test/java/com/sameerasw/essentials/island/CompactLayoutEngineTest.kt +++ b/app/src/test/java/com/sameerasw/essentials/island/CompactLayoutEngineTest.kt @@ -61,4 +61,18 @@ class CompactLayoutEngineTest { @Test fun soloCellIgnoredWhenAnchoredToSide() = assertEquals("| notif.icon", visual(listOf(chatCatchUp), CameraAnchor.Start)) + + private fun withBubble(entries: List, balance: Boolean): String { + val a = CompactLayoutEngine.arrange(entries, CameraAnchor.Center, leftExtra = 1, balance = balance) + return (a.before.reversed() + "|" + a.after).joinToString(" ") + } + + @Test fun bubbleWithoutBalanceKeepsLeftItems() = + assertEquals("flash.icon notif.icon | media.eq media.art", withBubble(listOf(catchUp, flash, media), balance = false)) + + @Test fun bubbleBalanceMovesLowestPriorityRight() = + assertEquals("notif.icon | media.eq media.art flash.icon", withBubble(listOf(catchUp, flash, media), balance = true)) + + @Test fun balanceLeavesPinnedOnLeft() = + assertEquals("time | media.eq media.art flash.icon", withBubble(listOf(time, flash, media), balance = true)) } 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 0000000000..6558c56b10 --- /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 0000000000..6b128edd01 --- /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) + } +} diff --git a/app/src/test/java/com/sameerasw/essentials/utils/DuoBatteryLevelsTest.kt b/app/src/test/java/com/sameerasw/essentials/utils/DuoBatteryLevelsTest.kt new file mode 100644 index 0000000000..11f0bf53eb --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/utils/DuoBatteryLevelsTest.kt @@ -0,0 +1,38 @@ +package com.sameerasw.essentials.utils + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DuoBatteryLevelsTest { + @Test + fun lowAboveCriticalIsKept() { + assertEquals(30 to 10, DuoBatteryLevels.withLow(30, 10)) + } + + @Test + fun lowBelowCriticalPullsCriticalDown() { + assertEquals(8 to 7, DuoBatteryLevels.withLow(8, 10)) + } + + @Test + fun lowIsClampedToRange() { + assertEquals(99 to 10, DuoBatteryLevels.withLow(150, 10)) + assertEquals(2 to 1, DuoBatteryLevels.withLow(-5, 10)) + } + + @Test + fun criticalAboveLowPushesLowUp() { + assertEquals(26 to 25, DuoBatteryLevels.withCritical(25, 20)) + } + + @Test + fun criticalBelowLowIsKept() { + assertEquals(20 to 15, DuoBatteryLevels.withCritical(15, 20)) + } + + @Test + fun criticalIsClampedToRange() { + assertEquals(99 to 98, DuoBatteryLevels.withCritical(500, 20)) + assertEquals(20 to 1, DuoBatteryLevels.withCritical(0, 20)) + } +}