Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,20 @@ 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
get() = ControlsMode.entries.firstOrNull { it.name == controlsMode } ?: ControlsMode.ALWAYS

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,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2431,7 +2431,6 @@ object FeatureRegistry {
permissionKeys = listOf("DRAW_OVERLAYS"),
showToggle = false,
hasMoreSettings = true,
isBeta = true,
parentFeatureId = "Widgets",
) {
override fun isEnabled(viewModel: MainViewModel) = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -66,6 +75,11 @@ object StackHost {
private var overlayContainer: FrameLayout? = null
private val timers = mutableMapOf<Int, Runnable>()
private val pendingRenders = mutableMapOf<Int, Runnable>()
private val heldStacks = mutableSetOf<Int>()
private val manualStacks = mutableSetOf<Int>()
private var media: MediaSessionSource? = null
private var calendarSoon = false
private val scope = CoroutineScope(Dispatchers.IO)

private class CapturingHostView(
context: Context,
Expand Down Expand Up @@ -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()
}
}
}
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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,
)
Expand All @@ -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()
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -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<String> =
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) }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String?>,
playingPackage: String?,
calendarPackages: Set<String>,
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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<Int>())
private var pendingWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID

Expand All @@ -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)

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 6 additions & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3532,7 +3532,7 @@
<string name="widget_stack_cant_nest">A widget stack can\'t hold another stack</string>
<string name="feat_widget_stack_title">Widget stack</string>
<string name="feat_widget_stack_desc">Stack widgets and switch between them</string>
<string name="about_desc_widget_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. Widgets that show a scrolling list, such as a calendar agenda, can\'t be shown in a stack.</string>
<string name="about_desc_widget_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.</string>
<string name="widget_stack_add_to_home">Add a stack to the homescreen</string>
<string name="widget_stack_add_to_home_desc">Then pick the widgets it should hold</string>
<string name="widget_stack_your_stacks">Stacks on your homescreen</string>
Expand All @@ -3551,6 +3551,11 @@
<string name="widget_stack_move_up">Move up</string>
<string name="widget_stack_move_down">Move down</string>
<string name="widget_stack_max_reached">A stack can hold up to %1$d widgets</string>
<string name="widget_stack_smart_section">Smart rotation</string>
<string name="widget_stack_smart_title">Show what matters now</string>
<string name="widget_stack_smart_desc">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.</string>
<string name="widget_stack_smart_needs_access">Needs notification access to see what\'s playing</string>
<string name="widget_stack_smart_needs_calendar">Needs calendar access to see upcoming events</string>
<string name="widget_stack_interval_title">Switch automatically</string>
<string name="widget_stack_interval_off">Off</string>
<string name="widget_stack_interval_seconds">%1$ds</string>
Expand Down
Loading
Loading