From ee511059752313d4958cb23edd574b306baf9535 Mon Sep 17 00:00:00 2001 From: Joseph Kuriackal Date: Thu, 22 Oct 2026 13:27:00 +0530 Subject: [PATCH] feat: smart rotation for widget stacks A stack with Smart rotation on brings forward the widget that matters now and holds it, pausing the timer: the app playing music, a calendar app when an event starts within 30 minutes, or a battery widget while a charger is plugged in. It picks when the screen turns on or a signal changes, never while the stack is in view, and a manual switch leaves the stack alone until the screen turns off. Also drops the beta badge from Widget stack. Co-Authored-By: Claude Opus 5.5 --- .../domain/model/WidgetStackConfig.kt | 7 + .../domain/registry/FeatureRegistry.kt | 1 - .../essentials/services/widgets/StackHost.kt | 111 ++++++++++++++- .../services/widgets/StackSmartPick.kt | 28 ++++ .../WidgetStackConfigureActivity.kt | 70 +++++++++- app/src/main/res/values/strings.xml | 7 +- .../essentials/services/StackSmartPickTest.kt | 128 ++++++++++++++++++ 7 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/services/widgets/StackSmartPick.kt create mode 100644 app/src/test/java/com/sameerasw/essentials/services/StackSmartPickTest.kt diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/WidgetStackConfig.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/WidgetStackConfig.kt index 282dd3460..9ea47a5cf 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/WidgetStackConfig.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/WidgetStackConfig.kt @@ -18,6 +18,8 @@ data class WidgetStackConfig( val intervalSeconds: Int = DEFAULT_INTERVAL_SECONDS, // Stored by name; Gson leaves it null for stacks saved before it existed. private val controlsMode: String? = ControlsMode.ALWAYS.name, + // Null for stacks saved before it existed, which means off. + private val smartMode: Boolean? = null, ) { /** How the arrows and dots below the stack are shown. */ val controls: ControlsMode @@ -25,6 +27,11 @@ data class WidgetStackConfig( fun withControls(mode: ControlsMode) = copy(controlsMode = mode.name) + val smart: Boolean + get() = smartMode == true + + fun withSmart(on: Boolean) = copy(smartMode = on) + enum class ControlsMode { ALWAYS, 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..244646e8a 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 @@ -2431,7 +2431,6 @@ object FeatureRegistry { permissionKeys = listOf("DRAW_OVERLAYS"), showToggle = false, hasMoreSettings = true, - isBeta = true, parentFeatureId = "Widgets", ) { override fun isEnabled(viewModel: MainViewModel) = true diff --git a/app/src/main/java/com/sameerasw/essentials/services/widgets/StackHost.kt b/app/src/main/java/com/sameerasw/essentials/services/widgets/StackHost.kt index 3be4751a7..a033916ee 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/widgets/StackHost.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/widgets/StackHost.kt @@ -20,6 +20,8 @@ import android.content.Intent import android.content.IntentFilter import android.content.res.Configuration import android.graphics.PixelFormat +import android.media.session.PlaybackState +import android.os.BatteryManager import android.os.Handler import android.os.Looper import android.os.PowerManager @@ -35,6 +37,13 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import com.sameerasw.essentials.data.repository.WidgetStackRepository import com.sameerasw.essentials.domain.model.WidgetStackConfig +import com.sameerasw.essentials.island.plugins.media.MediaSessionSource +import com.sameerasw.essentials.utils.CalendarEventUtil +import com.sameerasw.essentials.utils.PermissionUtils +import com.sameerasw.essentials.utils.battery.BatteryInfoUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit @@ -66,6 +75,11 @@ object StackHost { private var overlayContainer: FrameLayout? = null private val timers = mutableMapOf() private val pendingRenders = mutableMapOf() + private val heldStacks = mutableSetOf() + private val manualStacks = mutableSetOf() + private var media: MediaSessionSource? = null + private var calendarSoon = false + private val scope = CoroutineScope(Dispatchers.IO) private class CapturingHostView( context: Context, @@ -104,10 +118,18 @@ object StackHost { when (intent.action) { Intent.ACTION_SCREEN_OFF -> { cancelAllTimers() + manualStacks.clear() repository.savePositions(positions) } - Intent.ACTION_SCREEN_ON -> repository.getAll().forEach { scheduleTimer(it.stackWidgetId) } + Intent.ACTION_SCREEN_ON -> { + repository.getAll().forEach { scheduleTimer(it.stackWidgetId) } + refreshCalendarThenPick() + } + + Intent.ACTION_POWER_CONNECTED, + Intent.ACTION_POWER_DISCONNECTED, + -> smartPickAll() } } } @@ -147,6 +169,8 @@ object StackHost { handler.post { if (host == null) start(app) reveal(stackWidgetId) + manualStacks.add(stackWidgetId) + heldStacks.remove(stackWidgetId) advance(stackWidgetId, delta) positions[stackWidgetId]?.let { pos -> repository.savePositions(mapOf(stackWidgetId to pos)) @@ -198,6 +222,8 @@ object StackHost { IntentFilter().apply { addAction(Intent.ACTION_SCREEN_ON) addAction(Intent.ACTION_SCREEN_OFF) + addAction(Intent.ACTION_POWER_CONNECTED) + addAction(Intent.ACTION_POWER_DISCONNECTED) }, ContextCompat.RECEIVER_NOT_EXPORTED, ) @@ -208,6 +234,11 @@ object StackHost { WorkManager.getInstance(appContext).cancelUniqueWork(KEEP_ALIVE_WORK) handler.removeCallbacksAndMessages(null) timers.clear() + heldStacks.clear() + manualStacks.clear() + media?.stop() + media = null + calendarSoon = false pendingRenders.clear() hideRunnables.clear() revealedStacks.clear() @@ -268,7 +299,20 @@ object StackHost { StackWidgetProvider.render(appContext, stack.stackWidgetId) scheduleTimer(stack.stackWidgetId) } - (timers.keys - stacks.map { it.stackWidgetId }.toSet()).forEach { cancelTimer(it) } + val stackIds = stacks.map { it.stackWidgetId }.toSet() + (timers.keys - stackIds).forEach { cancelTimer(it) } + heldStacks.removeAll { it !in stackIds } + manualStacks.removeAll { it !in stackIds } + + if (stacks.any { it.smart } && PermissionUtils.hasNotificationListenerPermission(appContext)) { + if (media == null) { + media = MediaSessionSource(appContext, handler) { smartPickAll() }.also { it.start() } + } + } else { + media?.stop() + media = null + } + refreshCalendarThenPick() } private fun removeOrphanedWidgets() { @@ -341,6 +385,7 @@ object StackHost { private fun scheduleTimer(stackWidgetId: Int) { cancelTimer(stackWidgetId) + if (stackWidgetId in heldStacks) return val stack = repository.get(stackWidgetId) ?: return if (stack.intervalSeconds <= 0 || stack.hostedWidgetIds.size < 2) return val power = appContext.getSystemService(Context.POWER_SERVICE) as PowerManager @@ -351,6 +396,68 @@ object StackHost { handler.postDelayed(tick, stack.intervalSeconds * 1000L) } + private fun smartPick(stackWidgetId: Int) { + val stack = repository.get(stackWidgetId) ?: return + if (!stack.smart || stack.hostedWidgetIds.size < 2 || stackWidgetId in manualStacks) { + if (heldStacks.remove(stackWidgetId)) { + scheduleTimer(stackWidgetId) + } + return + } + + val awm = AppWidgetManager.getInstance(appContext) + val hostedComponents = stack.hostedWidgetIds.map { awm.getAppWidgetInfo(it)?.provider?.flattenToString() } + val index = + StackSmartPick.pick( + hostedComponents = hostedComponents, + playingPackage = playingPackage(), + calendarPackages = if (calendarSoon) calendarPackages() else emptySet(), + charging = (BatteryInfoUtil.getBatteryIntent(appContext)?.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) ?: 0) > 0, + ) + if (index == null) { + if (heldStacks.remove(stackWidgetId)) { + scheduleTimer(stackWidgetId) + } + } else { + heldStacks.add(stackWidgetId) + val current = positions[stackWidgetId] ?: 0 + if (index != current) { + positions[stackWidgetId] = index + StackWidgetProvider.render(appContext, stackWidgetId) + } + scheduleTimer(stackWidgetId) + } + } + + private fun calendarPackages(): Set = + appContext.packageManager + .queryIntentActivities( + Intent.makeMainSelectorActivity(Intent.ACTION_MAIN, Intent.CATEGORY_APP_CALENDAR), + 0, + ).mapNotNull { it.activityInfo?.packageName } + .toSet() + + private fun refreshCalendarThenPick() { + if (repository.getAll().none { it.smart }) { + calendarSoon = false + smartPickAll() + return + } + scope.launch { + val soon = CalendarEventUtil.queryNextUpcomingEvent(appContext, "30m", emptySet()) != null + handler.post { + calendarSoon = soon + smartPickAll() + } + } + } + + private fun smartPickAll() { + repository.getAll().forEach { smartPick(it.stackWidgetId) } + } + + private fun playingPackage(): String? = media?.all?.firstOrNull { it.playbackState?.state == PlaybackState.STATE_PLAYING }?.packageName + private fun cancelTimer(stackWidgetId: Int) { timers.remove(stackWidgetId)?.let { handler.removeCallbacks(it) } } diff --git a/app/src/main/java/com/sameerasw/essentials/services/widgets/StackSmartPick.kt b/app/src/main/java/com/sameerasw/essentials/services/widgets/StackSmartPick.kt new file mode 100644 index 000000000..91a6312d8 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/widgets/StackSmartPick.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Background Services & Receivers + * File: StackSmartPick.kt + * Description: Picks the most relevant widget to show in a stack. + */ + +package com.sameerasw.essentials.services.widgets + +object StackSmartPick { + fun pick( + hostedComponents: List, + playingPackage: String?, + calendarPackages: Set, + charging: Boolean, + ): Int? { + fun first(match: (pkg: String, cls: String) -> Boolean): Int? = + hostedComponents + .indexOfFirst { it != null && match(it.substringBefore('/'), it.substringAfter('/', "")) } + .takeIf { it >= 0 } + + return playingPackage?.let { playing -> first { pkg, _ -> pkg == playing } } + ?: first { pkg, _ -> pkg in calendarPackages } + ?: if (charging) first { _, cls -> cls.contains("batter", ignoreCase = true) } else null + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/WidgetStackConfigureActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/WidgetStackConfigureActivity.kt index f9e4c8772..5d3450867 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/WidgetStackConfigureActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/WidgetStackConfigureActivity.kt @@ -9,12 +9,14 @@ package com.sameerasw.essentials.ui.activities +import android.Manifest import android.app.Activity import android.appwidget.AppWidgetHost import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProviderInfo import android.content.ComponentName import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle @@ -49,6 +51,7 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -67,9 +70,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.viewmodel.compose.viewModel -import kotlinx.coroutines.delay import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.WidgetStackRepository import com.sameerasw.essentials.domain.model.WidgetStackConfig @@ -79,7 +82,9 @@ import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.features.widgets.WidgetPickerSheet import com.sameerasw.essentials.ui.theme.EssentialsTheme import com.sameerasw.essentials.utils.HapticUtil +import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.viewmodels.MainViewModel +import kotlinx.coroutines.delay class WidgetStackConfigureActivity : ComponentActivity() { private lateinit var repository: WidgetStackRepository @@ -88,6 +93,8 @@ class WidgetStackConfigureActivity : ComponentActivity() { private var stackWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID private var config by mutableStateOf(WidgetStackConfig(AppWidgetManager.INVALID_APPWIDGET_ID)) private var overlayGranted by mutableStateOf(true) + private var notificationAccess by mutableStateOf(true) + private var calendarAccess by mutableStateOf(true) private var unsupported by mutableStateOf(emptySet()) private var pendingWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID @@ -99,6 +106,12 @@ class WidgetStackConfigureActivity : ComponentActivity() { if (result.resultCode == RESULT_OK && info != null) onWidgetBound(info) else discardPending() } + private val calendarPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + calendarAccess = granted + if (granted) StackHost.refresh(this) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -155,6 +168,10 @@ class WidgetStackConfigureActivity : ComponentActivity() { override fun onResume() { super.onResume() overlayGranted = Settings.canDrawOverlays(this) + notificationAccess = PermissionUtils.hasNotificationListenerPermission(this) + calendarAccess = + ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) == + PackageManager.PERMISSION_GRANTED unsupported = StackHost.unsupported.toSet() if (overlayGranted) StackHost.refresh(this) } @@ -369,6 +386,57 @@ class WidgetStackConfigureActivity : ComponentActivity() { ) } + SectionTitle(stringResource(R.string.widget_stack_smart_section)) + RoundedCardContainer { + ListItem( + modifier = Modifier.clip(MaterialTheme.shapes.extraSmall), + headlineContent = { Text(stringResource(R.string.widget_stack_smart_title)) }, + supportingContent = { Text(stringResource(R.string.widget_stack_smart_desc)) }, + trailingContent = { + Switch( + checked = config.smart, + onCheckedChange = { + HapticUtil.performUIHaptic(view) + updateConfig(config.withSmart(it)) + }, + ) + }, + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceBright), + ) + if (config.smart && !notificationAccess) { + ListItem( + modifier = Modifier.clip(MaterialTheme.shapes.extraSmall), + headlineContent = { Text(stringResource(R.string.widget_stack_smart_needs_access)) }, + trailingContent = { + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) + }, + colors = ButtonDefaults.filledTonalButtonColors(), + ) { Text(stringResource(R.string.widget_stack_grant)) } + }, + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceBright), + ) + } + if (config.smart && !calendarAccess) { + ListItem( + modifier = Modifier.clip(MaterialTheme.shapes.extraSmall), + headlineContent = { Text(stringResource(R.string.widget_stack_smart_needs_calendar)) }, + trailingContent = { + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + calendarPermissionLauncher.launch(Manifest.permission.READ_CALENDAR) + }, + colors = ButtonDefaults.filledTonalButtonColors(), + ) { Text(stringResource(R.string.widget_stack_grant)) } + }, + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceBright), + ) + } + } + SectionTitle(stringResource(R.string.widget_stack_interval_title)) RoundedCardContainer { ListItem( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f97275599..00983298d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3532,7 +3532,7 @@ A widget stack can\'t hold another stack Widget stack Stack widgets and switch between them - Put several widgets from any app in one spot on your homescreen.\n\nThe stack switches to the next widget on a timer, or you can switch with the arrows below it. Widgets that show a scrolling list, such as a calendar agenda, can\'t be shown in a stack. + Put several widgets from any app in one spot on your homescreen.\n\nThe stack switches to the next widget on a timer, or you can switch with the arrows below it. With Smart rotation on, it brings forward the widget that matters now, such as the app playing music. Widgets that show a scrolling list, such as a calendar agenda, can\'t be shown in a stack. Add a stack to the homescreen Then pick the widgets it should hold Stacks on your homescreen @@ -3551,6 +3551,11 @@ Move up Move down A stack can hold up to %1$d widgets + Smart rotation + Show what matters now + Brings forward the widget of the app playing music, your calendar when an event starts soon, and a battery widget while charging. If you switch yourself, it waits until the screen turns off. + Needs notification access to see what\'s playing + Needs calendar access to see upcoming events Switch automatically Off %1$ds diff --git a/app/src/test/java/com/sameerasw/essentials/services/StackSmartPickTest.kt b/app/src/test/java/com/sameerasw/essentials/services/StackSmartPickTest.kt new file mode 100644 index 000000000..e1a6a84d3 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/services/StackSmartPickTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Unit Tests - Services + * File: StackSmartPickTest.kt + * Description: Unit tests for StackSmartPick logic. + */ + +package com.sameerasw.essentials.services + +import com.sameerasw.essentials.services.widgets.StackSmartPick +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class StackSmartPickTest { + @Test + fun picksPlayingAppWidget() { + val hosted = listOf("com.google.android.deskclock/.ClockWidget", "com.spotify.music/.NowPlayingWidget") + assertEquals(1, StackSmartPick.pick(hosted, "com.spotify.music", emptySet(), false)) + } + + @Test + fun picksCalendarAppWidgetWhenInCalendarPackages() { + val hosted = listOf("com.google.android.deskclock/.ClockWidget", "com.google.android.calendar/.MonthWidget") + assertEquals(1, StackSmartPick.pick(hosted, null, setOf("com.google.android.calendar"), false)) + } + + @Test + fun doesNotPickCalendarWidgetWhenNotInCalendarPackages() { + val hosted = listOf("com.google.android.deskclock/.ClockWidget", "com.google.android.calendar/.MonthWidget") + assertNull(StackSmartPick.pick(hosted, null, emptySet(), false)) + } + + @Test + fun picksBatteryWidgetWhenCharging() { + val hosted = listOf("com.google.android.deskclock/.ClockWidget", "com.sameerasw.essentials/.BatteryWidget") + assertEquals(1, StackSmartPick.pick(hosted, null, emptySet(), true)) + } + + @Test + fun picksBatteriesWidgetWhenCharging() { + val hosted = listOf("com.google.android.deskclock/.ClockWidget", "com.sameerasw.essentials/.BatteriesWidget") + assertEquals(1, StackSmartPick.pick(hosted, null, emptySet(), true)) + } + + @Test + fun doesNotPickBatteryWidgetWhenNotCharging() { + val hosted = listOf("com.google.android.deskclock/.ClockWidget", "com.sameerasw.essentials/.BatteryWidget") + assertNull(StackSmartPick.pick(hosted, null, emptySet(), false)) + } + + @Test + fun doesNotPickWidgetWhenPackageContainsBatteryButClassDoesNot() { + val hosted = listOf("com.battery.saver/.ClockWidget") + assertNull(StackSmartPick.pick(hosted, null, emptySet(), true)) + } + + @Test + fun priorityMusicBeatsCalendarAndChargingEvenWhenLowerPriorityWidgetComesFirst() { + val hosted = + listOf( + "com.sameerasw.essentials/.BatteryWidget", + "com.google.android.calendar/.MonthWidget", + "com.spotify.music/.NowPlayingWidget", + ) + assertEquals(2, StackSmartPick.pick(hosted, "com.spotify.music", setOf("com.google.android.calendar"), true)) + } + + @Test + fun priorityCalendarBeatsChargingEvenWhenLowerPriorityWidgetComesFirst() { + val hosted = + listOf( + "com.sameerasw.essentials/.BatteryWidget", + "com.google.android.calendar/.MonthWidget", + ) + assertEquals(1, StackSmartPick.pick(hosted, null, setOf("com.google.android.calendar"), true)) + } + + @Test + fun firstWinsWhenTwoWidgetsMatchSameRule() { + val hostedMusic = + listOf( + "com.spotify.music/.WidgetOne", + "com.google.android.deskclock/.Clock", + "com.spotify.music/.WidgetTwo", + ) + assertEquals(0, StackSmartPick.pick(hostedMusic, "com.spotify.music", emptySet(), false)) + + val hostedCalendar = + listOf( + "com.google.android.calendar/.WidgetOne", + "com.google.android.calendar/.WidgetTwo", + ) + assertEquals(0, StackSmartPick.pick(hostedCalendar, null, setOf("com.google.android.calendar"), false)) + + val hostedBattery = + listOf( + "com.example/.BatterySmall", + "com.example/.BatteryLarge", + ) + assertEquals(0, StackSmartPick.pick(hostedBattery, null, emptySet(), true)) + } + + @Test + fun nullComponentsNeverMatch() { + val hosted = listOf(null, null) + assertNull(StackSmartPick.pick(hosted, "com.spotify.music", setOf("com.google.android.calendar"), true)) + } + + @Test + fun nothingMatchingReturnsNull() { + val hosted = listOf("com.google.android.deskclock/.ClockWidget", "com.example.notes/.NotesWidget") + assertNull(StackSmartPick.pick(hosted, "com.spotify.music", setOf("com.google.android.calendar"), false)) + } + + @Test + fun emptyHostedListReturnsNull() { + assertNull(StackSmartPick.pick(emptyList(), "com.spotify.music", setOf("com.google.android.calendar"), true)) + } + + @Test + fun skipsNullHostedComponentsWhenFindingMatch() { + val hosted = listOf(null, "com.spotify.music/.NowPlayingWidget") + assertEquals(1, StackSmartPick.pick(hosted, "com.spotify.music", emptySet(), false)) + } +}