From 20c9f02bfcbe6cbed55a4db92feecc9bbf2b57c7 Mon Sep 17 00:00:00 2001 From: Joseph Kuriackal Date: Fri, 16 Oct 2026 19:25:03 +0530 Subject: [PATCH 1/2] feat: widget stack remembers its position across reboots Each stack's current widget was only kept in memory, so a reboot, app update or killed process sent every stack back to its first widget. Positions are now saved per stack in widget_stack_prefs after an arrow or edge step, when the screen turns off and when the app is moved to the background, and restored (clamped to the hosted widgets) on start. Co-Authored-By: Claude Opus 5.5 --- .../data/repository/WidgetStackRepository.kt | 29 ++++++++ .../essentials/services/widgets/StackHost.kt | 23 +++++-- .../services/widgets/StackPositions.kt | 26 +++++++ .../essentials/services/StackPositionsTest.kt | 67 +++++++++++++++++++ 4 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/services/widgets/StackPositions.kt create mode 100644 app/src/test/java/com/sameerasw/essentials/services/StackPositionsTest.kt diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/WidgetStackRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/WidgetStackRepository.kt index e6456016d..1f43f349c 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/WidgetStackRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/WidgetStackRepository.kt @@ -11,6 +11,7 @@ package com.sameerasw.essentials.data.repository import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.sameerasw.essentials.domain.model.WidgetStackConfig @@ -43,6 +44,32 @@ class WidgetStackRepository( fun get(stackWidgetId: Int): WidgetStackConfig? = getAll().firstOrNull { it.stackWidgetId == stackWidgetId } + fun getPositions(): Map { + val result = mutableMapOf() + for ((key, value) in prefs.all) { + if (key.startsWith(KEY_POSITION_PREFIX) && value is Int) { + val id = key.removePrefix(KEY_POSITION_PREFIX).toIntOrNull() ?: continue + result[id] = value + } + } + return result + } + + fun savePositions(positions: Map) { + val toSave = + positions.filter { (id, pos) -> + val key = KEY_POSITION_PREFIX + id + val stored = if (prefs.contains(key)) prefs.getInt(key, 0) else null + stored != pos + } + if (toSave.isEmpty()) return + prefs.edit { + for ((id, pos) in toSave) { + putInt(KEY_POSITION_PREFIX + id, pos) + } + } + } + fun save(config: WidgetStackConfig) = synchronized(LOCK) { write(getAll().filterNot { it.stackWidgetId == config.stackWidgetId } + config) @@ -51,6 +78,7 @@ class WidgetStackRepository( fun delete(stackWidgetId: Int) = synchronized(LOCK) { write(getAll().filterNot { it.stackWidgetId == stackWidgetId }) + prefs.edit { remove(KEY_POSITION_PREFIX + stackWidgetId) } } private fun write(stacks: List) { @@ -63,6 +91,7 @@ class WidgetStackRepository( companion object { private const val PREFS_NAME = "widget_stack_prefs" private const val KEY_STACKS = "stacks" + private const val KEY_POSITION_PREFIX = "position_" private val LOCK = Any() private var cachedJson: String? = null private var cachedStacks: List = emptyList() 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 620e53aec..1c82ca0a3 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 @@ -14,7 +14,7 @@ import android.appwidget.AppWidgetHostView import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProviderInfo import android.content.BroadcastReceiver -import android.content.ComponentCallbacks +import android.content.ComponentCallbacks2 import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -102,20 +102,31 @@ object StackHost { intent: Intent, ) { when (intent.action) { - Intent.ACTION_SCREEN_OFF -> cancelAllTimers() - Intent.ACTION_SCREEN_ON -> repository.getAll().forEach { scheduleTimer(it.stackWidgetId) } + Intent.ACTION_SCREEN_OFF -> { + cancelAllTimers() + repository.savePositions(positions) + } + + Intent.ACTION_SCREEN_ON -> { + repository.getAll().forEach { scheduleTimer(it.stackWidgetId) } + } } } } private val configurationCallbacks = - object : ComponentCallbacks { + object : ComponentCallbacks2 { override fun onConfigurationChanged(newConfig: Configuration) { repository.getAll().forEach { StackWidgetProvider.forwardSize(appContext, it) } } @Deprecated("Deprecated in Java") override fun onLowMemory() {} + + override fun onTrimMemory(level: Int) { + // Cached apps get frozen and miss SCREEN_OFF, so save before that happens. + if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) repository.savePositions(positions) + } } fun startIfNeeded(context: Context) { @@ -140,6 +151,9 @@ object StackHost { if (host == null) start(app) reveal(stackWidgetId) advance(stackWidgetId, delta) + positions[stackWidgetId]?.let { pos -> + repository.savePositions(mapOf(stackWidgetId to pos)) + } } } @@ -177,6 +191,7 @@ object StackHost { PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build(), ) repository = WidgetStackRepository(context) + positions.putAll(StackPositions.restore(repository.getPositions(), repository.getAll())) host = CapturingWidgetHost(context).also { it.startListening() } removeOrphanedWidgets() attachOverlay() diff --git a/app/src/main/java/com/sameerasw/essentials/services/widgets/StackPositions.kt b/app/src/main/java/com/sameerasw/essentials/services/widgets/StackPositions.kt new file mode 100644 index 000000000..190913f30 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/widgets/StackPositions.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Background Services & Receivers + * File: StackPositions.kt + * Description: Restores and validates widget stack positions. + */ + +package com.sameerasw.essentials.services.widgets + +import com.sameerasw.essentials.domain.model.WidgetStackConfig + +object StackPositions { + fun restore( + saved: Map, + stacks: List, + ): Map = + buildMap { + for (stack in stacks) { + if (stack.hostedWidgetIds.isEmpty()) continue + val pos = saved[stack.stackWidgetId] ?: continue + put(stack.stackWidgetId, pos.coerceIn(0, stack.hostedWidgetIds.lastIndex)) + } + } +} diff --git a/app/src/test/java/com/sameerasw/essentials/services/StackPositionsTest.kt b/app/src/test/java/com/sameerasw/essentials/services/StackPositionsTest.kt new file mode 100644 index 000000000..a736441b5 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/services/StackPositionsTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Unit Tests - Services + * File: StackPositionsTest.kt + * Description: Unit tests for StackPositions restore logic. + */ + +package com.sameerasw.essentials.services + +import com.sameerasw.essentials.domain.model.WidgetStackConfig +import com.sameerasw.essentials.services.widgets.StackPositions +import org.junit.Assert.assertEquals +import org.junit.Test + +class StackPositionsTest { + @Test + fun keepsSavedPositionInRange() { + val stacks = listOf(WidgetStackConfig(stackWidgetId = 1, hostedWidgetIds = listOf(10, 11, 12))) + val saved = mapOf(1 to 1) + assertEquals(mapOf(1 to 1), StackPositions.restore(saved, stacks)) + } + + @Test + fun clampsSavedPositionAboveLastIndexToLastIndex() { + val stacks = listOf(WidgetStackConfig(stackWidgetId = 1, hostedWidgetIds = listOf(10, 11, 12))) + val saved = mapOf(1 to 5) + assertEquals(mapOf(1 to 2), StackPositions.restore(saved, stacks)) + } + + @Test + fun clampsNegativeSavedPositionToZero() { + val stacks = listOf(WidgetStackConfig(stackWidgetId = 1, hostedWidgetIds = listOf(10, 11, 12))) + val saved = mapOf(1 to -3) + assertEquals(mapOf(1 to 0), StackPositions.restore(saved, stacks)) + } + + @Test + fun dropsPositionsForStackIdsNotInStacks() { + val stacks = listOf(WidgetStackConfig(stackWidgetId = 1, hostedWidgetIds = listOf(10, 11))) + val saved = mapOf(1 to 1, 2 to 0, 99 to 3) + assertEquals(mapOf(1 to 1), StackPositions.restore(saved, stacks)) + } + + @Test + fun leavesOutStacksWithNoHostedWidgets() { + val stacks = + listOf( + WidgetStackConfig(stackWidgetId = 1, hostedWidgetIds = emptyList()), + WidgetStackConfig(stackWidgetId = 2, hostedWidgetIds = listOf(20)), + ) + val saved = mapOf(1 to 0, 2 to 0) + assertEquals(mapOf(2 to 0), StackPositions.restore(saved, stacks)) + } + + @Test + fun leavesOutStacksWithNoSavedPosition() { + val stacks = + listOf( + WidgetStackConfig(stackWidgetId = 1, hostedWidgetIds = listOf(10, 11)), + WidgetStackConfig(stackWidgetId = 2, hostedWidgetIds = listOf(20, 21)), + ) + val saved = mapOf(1 to 1) + assertEquals(mapOf(1 to 1), StackPositions.restore(saved, stacks)) + } +} From 35b92f2ef2d9b8e05f348b56e0ec23301fd8ce91 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 18 Oct 2026 09:01:25 +0530 Subject: [PATCH 2/2] Widget stack: drop comment and braces noise --- .../com/sameerasw/essentials/services/widgets/StackHost.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 1c82ca0a3..3be4751a7 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 @@ -107,9 +107,7 @@ object StackHost { repository.savePositions(positions) } - Intent.ACTION_SCREEN_ON -> { - repository.getAll().forEach { scheduleTimer(it.stackWidgetId) } - } + Intent.ACTION_SCREEN_ON -> repository.getAll().forEach { scheduleTimer(it.stackWidgetId) } } } } @@ -124,7 +122,6 @@ object StackHost { override fun onLowMemory() {} override fun onTrimMemory(level: Int) { - // Cached apps get frozen and miss SCREEN_OFF, so save before that happens. if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) repository.savePositions(positions) } }