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