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..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 @@ -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,28 @@ object StackHost { intent: Intent, ) { when (intent.action) { - Intent.ACTION_SCREEN_OFF -> cancelAllTimers() + 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) { + if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) repository.savePositions(positions) + } } fun startIfNeeded(context: Context) { @@ -140,6 +148,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 +188,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)) + } +}