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 dc499c7f5..8132b30ec 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 @@ -551,6 +551,7 @@ class SettingsRepository( const val KEY_ISLAND_BATTERY_HIDE_LOCKED = "island_battery_hide_locked" const val KEY_ISLAND_TIMEOUT_MS = "island_timeout_ms" const val KEY_ISLAND_SHOW_GLOW = "island_show_glow" + const val KEY_ISLAND_NOTIF_THEMED_BUBBLE = "island_notif_themed_bubble" const val KEY_ISLAND_EXPANDED_WIDTH = "island_expanded_width" const val KEY_ISLAND_EXPANDED_ROUNDNESS = "island_expanded_roundness" const val KEY_ISLAND_EXPANDED_PADDING = "island_expanded_padding" @@ -4106,6 +4107,12 @@ class SettingsRepository( fun isIslandShowGlowEnabled(): Boolean = getBoolean(KEY_ISLAND_SHOW_GLOW, true) fun setIslandShowGlowEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_SHOW_GLOW, enabled) + fun isIslandNotifThemedBubbleEnabled(): Boolean = getBoolean(KEY_ISLAND_NOTIF_THEMED_BUBBLE, false) + fun setIslandNotifThemedBubbleEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_NOTIF_THEMED_BUBBLE, enabled) + + fun isIslandNotifThemedBubbleActive(): Boolean = + isIslandNotifThemedBubbleEnabled() && getBoolean(KEY_ISLAND_THEMED_APP_ICONS, false) + fun getIslandExpandedWidth(): Float = getFloat(KEY_ISLAND_EXPANDED_WIDTH, 360f) fun setIslandExpandedWidth(value: Float) = putFloat(KEY_ISLAND_EXPANDED_WIDTH, value) 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 1ec301ff1..d16a336aa 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 @@ -32,5 +32,7 @@ data class ActiveNotificationAlert( val ongoing: Boolean = false, val isConversation: Boolean = false, val hubOnly: Boolean = false, + val themedIcon: Bitmap? = null, + val smallIcon: Bitmap? = null, ) 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 49fcfa500..afe29dc2f 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 @@ -887,6 +887,11 @@ object FeatureRegistry { R.string.island_notification_options_title, "island_notif_hub", ), + SearchSetting( + R.string.island_notif_themed_bubble_title, + R.string.island_notif_hub_title, + "island_notif_hub", + ), SearchSetting( R.string.island_show_calls_title, R.string.island_call_incoming, 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 12fc9de6e..ffb0ef1f0 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 @@ -11,6 +11,9 @@ import android.app.KeyguardManager import android.content.Context import android.os.PowerManager import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.runtime.Composable +import android.graphics.Bitmap import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.SettingsRepository @@ -34,6 +37,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.NotificationGlyph import com.sameerasw.essentials.island.ui.components.IslandIcon import com.sameerasw.essentials.services.NotificationListener import com.sameerasw.essentials.utils.HapticUtil @@ -48,6 +52,8 @@ class NotificationsPlugin : BaseIslandPlugin() { SettingsRepository.KEY_ISLAND_NOTIF_QUEUE_INLINE, SettingsRepository.KEY_ISLAND_LINE_STAGE_ENABLED, SettingsRepository.KEY_ISLAND_SHOW_GLOW, + SettingsRepository.KEY_ISLAND_NOTIF_THEMED_BUBBLE, + SettingsRepository.KEY_ISLAND_THEMED_APP_ICONS, SettingsRepository.KEY_ISLAND_NOTIF_QUEUE, SettingsRepository.KEY_ISLAND_NOTIF_SKIP_SILENT, SettingsRepository.KEY_ISLAND_NOTIF_FOLLOW_DND, @@ -565,7 +571,7 @@ class NotificationsPlugin : BaseIslandPlugin() { if (!entry.grouped) return stackIconFor(entry.lead, current = false, highlight = highlight) val icon = entry.lead.appIcon ?: entry.lead.icon return StackIcon(entry.key, onSelect = { select(entry.lead.key) }, highlight = highlight) { size -> - IslandBitmap(icon, size, circle = true, fallbackRes = R.drawable.rounded_notifications_unread_24) + AlertIcon(entry.lead, icon, size, circle = true) } } @@ -745,16 +751,16 @@ class NotificationsPlugin : BaseIslandPlugin() { compact = buildList { add( CompactCell("notif.icon") { - IslandBitmap(icon, 22.dp, fallbackRes = R.drawable.rounded_notifications_unread_24) + AlertIcon(alert, icon, 22.dp) }, ) val appIcon = alert.appIcon - if (!minimal() && alert.chatIcon != null && appIcon != null) { + if (!minimal() && !themedBubble() && alert.chatIcon != null && appIcon != null) { 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) }, + icon = { AlertIcon(alert, icon, 24.dp) }, start = sender, end = message, condensed = condensedLine(), @@ -802,24 +808,24 @@ class NotificationsPlugin : BaseIslandPlugin() { compact = buildList { add( CompactCell("notif.icon") { - IslandBitmap(icon, 22.dp, fallbackRes = R.drawable.rounded_notifications_unread_24) + AlertIcon(alert, icon, 22.dp) }, ) val appIcon = alert.appIcon - if (!minimal() && !hideChat && alert.chatIcon != null && appIcon != null) { + if (!minimal() && !themedBubble() && !hideChat && alert.chatIcon != null && appIcon != null) { add(CompactCell("notif.app", soloOnly = true) { IslandBitmap(appIcon, 22.dp, circle = true) }) } }, line = if (isOtp(alert)) { LineContent( - icon = { IslandBitmap(alert.appIcon ?: alert.icon, 24.dp, fallbackRes = R.drawable.rounded_lock_24) }, + icon = { AlertIcon(alert, 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) }, + icon = { AlertIcon(alert, icon, 24.dp) }, start = appNameFor(context, alert), end = "", condensed = condensedLine(), @@ -853,11 +859,11 @@ class NotificationsPlugin : BaseIslandPlugin() { placement = CompactPlacement.Dynamic, compact = listOf( CompactCell("notif.icon") { - IslandBitmap(icon, 22.dp, fallbackRes = R.drawable.rounded_notifications_unread_24) + AlertIcon(alert, icon, 22.dp) }, ), line = LineContent( - icon = { IslandBitmap(icon, 24.dp, fallbackRes = R.drawable.rounded_notifications_unread_24) }, + icon = { AlertIcon(alert, icon, 24.dp) }, start = sender, end = formatOtp(code), endSlot = { IslandIcon(R.drawable.rounded_content_copy_24, size = 18.dp) }, @@ -943,7 +949,24 @@ class NotificationsPlugin : BaseIslandPlugin() { private fun stackIconFor(alert: ActiveNotificationAlert, current: Boolean, highlight: Boolean = false): StackIcon { val icon = if ((concealed() && hidesChatPictures()) || (hub() && !settings.isIslandNotifHubPreferChatPictures())) alert.appIcon ?: alert.icon else alert.chatIcon ?: alert.appIcon ?: alert.icon return StackIcon(alert.key, current = current, onSelect = { select(alert.key) }, highlight = highlight) { size -> - IslandBitmap(icon, size, circle = true, fallbackRes = R.drawable.rounded_notifications_unread_24) + AlertIcon(alert, icon, size, circle = true) + } + } + + private fun themedBubble() = settings.isIslandNotifThemedBubbleActive() + + @Composable + private fun AlertIcon( + alert: ActiveNotificationAlert, + bitmap: Bitmap?, + size: Dp, + circle: Boolean = false, + fallbackRes: Int = R.drawable.rounded_notifications_unread_24, + ) { + if (themedBubble()) { + NotificationGlyph(alert.themedIcon, alert.smallIcon, alert.appColor?.let { Color(soften(it)) }, size, fallbackRes = fallbackRes) + } else { + IslandBitmap(bitmap, size, circle = circle, fallbackRes = fallbackRes) } } 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 b550b1e41..028e1db92 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 @@ -731,7 +731,6 @@ class IslandCoordinator( controller.balanceCompact = settings.isIslandBalanceCompactEnabled() controller.chipsEnabled = settings.getBoolean(SettingsRepository.KEY_ISLAND_CHIPS, false) controller.secondaryBubbleEnabled = settings.isIslandSecondaryBubbleEnabled() - controller.secondaryBubbleOverlap = settings.getIslandNotifQueueStyle() == SettingsRepository.ISLAND_NOTIF_QUEUE_STYLE_OVERLAP controller.relayout() controller.expandedTimeoutMs = settings.getIslandExpandedTimeoutMs() applyPreviewRing() @@ -825,6 +824,7 @@ class IslandCoordinator( "alarm" to SettingsRepository.KEY_ISLAND_ALARM_LAUNCHER_ONLY, ) val CONFIG_KEYS = setOf( + SettingsRepository.KEY_ISLAND_NOTIF_THEMED_BUBBLE, SettingsRepository.KEY_ISLAND_NOTIF_QUEUE_STYLE, SettingsRepository.KEY_ISLAND_NOTIF_QUEUE_INLINE, SettingsRepository.KEY_ISLAND_USE_AUTO_DETECT, diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt index c9df3f1a6..2b1df6627 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/components/IslandComponents.kt @@ -53,15 +53,51 @@ import com.sameerasw.essentials.island.ui.IslandTextStyles import androidx.compose.ui.unit.sp import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawBehind import androidx.compose.material3.Text import androidx.compose.foundation.layout.Row import com.sameerasw.essentials.island.ui.IslandMotion +import androidx.compose.foundation.background import kotlinx.coroutines.delay import kotlin.random.Random +@Composable +fun NotificationGlyph( + themedIcon: Bitmap?, + smallIcon: Bitmap?, + color: Color?, + size: Dp, + modifier: Modifier = Modifier, + fallbackRes: Int, +) { + val background = color ?: MaterialTheme.colorScheme.primary + val glyph = lerp(background, if (background.luminance() > 0.179f) Color.Black else Color.White, 0.8f) + Box( + modifier + .sizeIn(maxWidth = size, maxHeight = size) + .aspectRatio(1f, matchHeightConstraintsFirst = true) + .clip(CircleShape) + .background(background), + contentAlignment = Alignment.Center, + ) { + val icon = themedIcon ?: smallIcon + if (icon != null) { + Image( + bitmap = remember(icon) { icon.asImageBitmap() }, + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(glyph), + modifier = Modifier.fillMaxSize(if (themedIcon != null) 1f else 0.6f), + ) + } else { + Icon(painterResource(fallbackRes), null, tint = glyph, modifier = Modifier.fillMaxSize(0.6f)) + } + } +} + @Composable fun IslandBitmap( bitmap: Bitmap?, 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 929b0a581..2fb45a60b 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt @@ -2353,12 +2353,23 @@ class NotificationListener : NotificationListenerService() { bitmap = appIcon } + var smallIconBitmap: Bitmap? = null + try { + notif.smallIcon?.loadDrawable(this)?.let { smallIconBitmap = AppUtil.drawableToBitmap(it) } + } catch (_: Exception) {} + + val themedBubble = SettingsRepository(this).isIslandNotifThemedBubbleActive() + val paletteSource = if (themedBubble) { + runCatching { AppUtil.drawableToBitmap(packageManager.getApplicationIcon(sbn.packageName)) }.getOrNull() ?: bitmap + } else { + bitmap + } var appColor: Int? = null if (notif.color != 0 && notif.color != android.graphics.Color.TRANSPARENT) { appColor = notif.color - } else if (bitmap != null) { + } else if (paletteSource != null) { try { - val palette = androidx.palette.graphics.Palette.from(bitmap).generate() + val palette = androidx.palette.graphics.Palette.from(paletteSource).generate() val vibrant = palette.getVibrantColor(0) val dominant = palette.getDominantColor(0) if (vibrant != 0) { @@ -2403,6 +2414,8 @@ class NotificationListener : NotificationListenerService() { appIcon = appIcon, actions = actionList, chatIcon = chatIcon, + themedIcon = if (themedBubble) AppIconUtil.monochromeAppIcon(this, sbn.packageName) else null, + smallIcon = smallIconBitmap, ) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNotificationHubOptionsBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNotificationHubOptionsBottomSheet.kt index 46ae00f8d..a7e2c63ca 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNotificationHubOptionsBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandNotificationHubOptionsBottomSheet.kt @@ -8,6 +8,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource @@ -29,6 +31,8 @@ fun IslandNotificationHubOptionsBottomSheet( onDismissRequest: () -> Unit, ) { val view = LocalView.current + val context = LocalContext.current + val themedAppIcons = remember { SettingsRepository(context).getBoolean(SettingsRepository.KEY_ISLAND_THEMED_APP_ICONS, false) } EssentialsBottomSheet(onDismissRequest = onDismissRequest) { Column( @@ -90,11 +94,23 @@ fun IslandNotificationHubOptionsBottomSheet( iconRes = R.drawable.rounded_person_24, title = stringResource(R.string.island_notif_hub_chat_pictures), isChecked = viewModel.isIslandNotifHubPreferChatPictures.value, + enabled = !(viewModel.isIslandNotifThemedBubble.value && themedAppIcons), onCheckedChange = { HapticUtil.performVirtualKeyHaptic(view) viewModel.setIslandNotifHubPreferChatPictures(it) }, ) + IconToggleItem( + iconRes = R.drawable.rounded_invert_colors_24, + title = stringResource(R.string.island_notif_themed_bubble_title), + description = if (themedAppIcons) null else stringResource(R.string.island_notif_themed_bubble_needs_themed_icons), + isChecked = viewModel.isIslandNotifThemedBubble.value && themedAppIcons, + enabled = themedAppIcons, + onCheckedChange = { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setIslandNotifThemedBubble(it) + }, + ) ConfigSliderItem( title = stringResource(R.string.island_hide_icon_notifications), value = viewModel.islandNotifHubIcons.intValue.toFloat(), diff --git a/app/src/main/java/com/sameerasw/essentials/utils/AppIconUtil.kt b/app/src/main/java/com/sameerasw/essentials/utils/AppIconUtil.kt index d79ac292d..c62bb035d 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/AppIconUtil.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/AppIconUtil.kt @@ -55,6 +55,19 @@ object AppIconUtil { val icon = pm.getApplicationIcon(packageName) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return icon if (!SettingsRepository(context).getBoolean(SettingsRepository.KEY_ISLAND_THEMED_APP_ICONS, false)) return icon + return themed(context, monochromeCandidate(pm, packageName, icon) ?: icon) ?: icon + } + + fun monochromeAppIcon(context: Context, packageName: String): Bitmap? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return null + val pm = context.packageManager + val icon = runCatching { pm.getApplicationIcon(packageName) }.getOrNull() ?: return null + val adaptive = monochromeCandidate(pm, packageName, icon) ?: return null + return (themed(context, adaptive) as? BitmapDrawable)?.bitmap + } + + @androidx.annotation.RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun monochromeCandidate(pm: PackageManager, packageName: String, icon: Drawable): Drawable? { val launcher = android.content.Intent(android.content.Intent.ACTION_MAIN).addCategory(android.content.Intent.CATEGORY_LAUNCHER).setPackage(packageName) val candidates = listOf(icon) + ( try { @@ -63,8 +76,7 @@ object AppIconUtil { emptyList() } ) - val withMonochrome = candidates.firstOrNull { (it as? AdaptiveIconDrawable)?.monochrome != null } - return themed(context, withMonochrome ?: icon) ?: icon + return candidates.firstOrNull { (it as? AdaptiveIconDrawable)?.monochrome != null } } enum class ThemedMode { MONO, DUO } 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 41e0e7596..ff8a101bc 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -303,6 +303,7 @@ class MainViewModel : ViewModel() { val isIslandOtpEnabled = mutableStateOf(false) val islandOtpTimeoutMs = mutableLongStateOf(60_000L) val isIslandShowGlow = mutableStateOf(true) + val isIslandNotifThemedBubble = mutableStateOf(false) val isIslandBorderOutline = mutableStateOf(false) val islandBorderOutlineColor = mutableStateOf(SettingsRepository.ISLAND_BORDER_OUTLINE_DEFAULT_COLOR) val isIslandBorderOutlineDynamic = mutableStateOf(false) @@ -2596,6 +2597,7 @@ class MainViewModel : ViewModel() { isIslandOtpEnabled.value = settingsRepository.isIslandOtpEnabled() islandOtpTimeoutMs.longValue = settingsRepository.getIslandOtpTimeoutMs() isIslandShowGlow.value = settingsRepository.isIslandShowGlowEnabled() + isIslandNotifThemedBubble.value = settingsRepository.isIslandNotifThemedBubbleEnabled() isIslandBorderOutline.value = settingsRepository.isIslandBorderOutlineEnabled() islandBorderOutlineColor.value = settingsRepository.getIslandBorderOutlineColor() isIslandBorderOutlineDynamic.value = settingsRepository.isIslandBorderOutlineDynamicEnabled() @@ -6199,6 +6201,11 @@ class MainViewModel : ViewModel() { settingsRepository.setIslandShowGlowEnabled(enabled) } + fun setIslandNotifThemedBubble(enabled: Boolean) { + isIslandNotifThemedBubble.value = enabled + settingsRepository.setIslandNotifThemedBubbleEnabled(enabled) + } + fun setIslandBorderOutline(enabled: Boolean) { isIslandBorderOutline.value = enabled settingsRepository.setIslandBorderOutlineEnabled(enabled) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e08d93c08..2c89a5122 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3231,6 +3231,8 @@ Glow spread Pulse duration App based color glow + App icon color bubbles + Turn on Use themed app icons in Visuals Expanded width Camera gap Width when long pressed to expand