From 63153223b8c721a9024f0c75fb47c35661270792 Mon Sep 17 00:00:00 2001 From: McCal-Codes <175259256+McCal-Codes@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:41:45 -0400 Subject: [PATCH 1/7] Double tap works on empty Home again (#61) Each empty cell takes its own taps, and since More rows made those cells cover most of a page, a double tap on empty space never reached the page's Double Tap action. Empty cells run it themselves now. Only empty cells outside jiggle mode, so a tap on an app or folder isn't held back waiting for a second one. --- app/src/main/java/com/mccal/folio/HomeWorkspace.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/mccal/folio/HomeWorkspace.kt b/app/src/main/java/com/mccal/folio/HomeWorkspace.kt index d58e138..ce32894 100644 --- a/app/src/main/java/com/mccal/folio/HomeWorkspace.kt +++ b/app/src/main/java/com/mccal/folio/HomeWorkspace.kt @@ -336,7 +336,8 @@ internal fun HomePagePane( SharedHomeGrid(page, state.homeSlots, state.leadingSlots, previewSlots, previewLeadingSlots, previewWidgetPlacements, appsById, geometry.copy(iconSize = pageIcon), pageLabels, widgets, drag, target, folders = state.folders, onLaunch = onLaunch, onActions = onActions, onWidget = onWidget, - onFolder = onFolder, onEmptyWidget = onEmptyWidget, onMove = onMove) + onFolder = onFolder, onEmptyWidget = onEmptyWidget, onMove = onMove, + onEmptyDoubleTap = if (doubleTapAction == FolioAction.NONE) null else ({ FolioActions.run(context, doubleTapAction) })) if (state.loading) LinearProgressIndicator(Modifier.fillMaxWidth().padding(16.dp)) if (state.error != null) Text(state.error, color = Color.White, modifier = Modifier.clickable(onClick = onRefresh).padding(12.dp)) @@ -375,6 +376,8 @@ internal fun SharedHomeGrid( onFolder: (String) -> Unit, onEmptyWidget: (Int) -> Unit, onMove: (String, Int) -> Unit = { _, _ -> }, + /** The Double Tap action, for empty cells: each cell takes its own taps, so the page behind never sees them. */ + onEmptyDoubleTap: (() -> Unit)? = null, ) { val rowHeight = geometry.rowHeight val iconSize = geometry.iconSize @@ -442,7 +445,10 @@ internal fun SharedHomeGrid( // Keyboard and switch focus goes to the app or folder itself, not the empty cell behind it. .focusProperties { canFocus = false } .combinedClickable(onClick = { if (savedFolder != null) onFolder(savedFolder.id) else if (edit.active) edit.stop() }, - onLongClick = { if (savedId == null && !drag.active) onEmptyWidget(globalIndex) }) + onLongClick = { if (savedId == null && !drag.active) onEmptyWidget(globalIndex) }, + // Only on an empty cell outside jiggle mode, so a tap on an app or folder isn't held back waiting + // for a second one. + onDoubleClick = onEmptyDoubleTap?.takeIf { savedId == null && !edit.active && !drag.active }) .background(if (highlighted) Glass.copy(alpha = .25f) else Color.Transparent, RoundedCornerShape(16.dp)) .border(if (highlighted) 2.dp else 0.dp, if (highlighted) Color.White.copy(alpha = .8f) else Color.Transparent, RoundedCornerShape(16.dp)), From ed7e75b04c55dd466c7431ce5d49e569fe66f14d Mon Sep 17 00:00:00 2001 From: McCal-Codes <175259256+McCal-Codes@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:41:45 -0400 Subject: [PATCH 2/7] The dock steps aside for Today View (#25) Unfolded and upright, the dock bar sat over Today View's widgets and Edit button. Like iPhone, it now fades and slides away as you swipe to Today View, following the swipe inside the layer so nothing recomposes, and leaves once you're there so it can't take Today's taps. It comes back on Home. Today View's search field also reads its label in your language. --- .../main/java/com/mccal/folio/LauncherScreen.kt | 14 +++++++++++++- app/src/main/java/com/mccal/folio/TodayView.kt | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/mccal/folio/LauncherScreen.kt b/app/src/main/java/com/mccal/folio/LauncherScreen.kt index df90373..6af83a0 100644 --- a/app/src/main/java/com/mccal/folio/LauncherScreen.kt +++ b/app/src/main/java/com/mccal/folio/LauncherScreen.kt @@ -796,7 +796,13 @@ fun LauncherScreen( // Portrait unfolded (iPhone Duo): a horizontal dock bar centered along the bottom, above the page controls. val dockPitch = geometry.dockPitch val dockBarWidth = (dockPitch * state.dock.size + 16f).dp - Box((if (geometry.horizontalDock) (if (hinge?.active == true && hinge.vertical) + // Like iPhone, the dock bar steps aside for Today View: it follows the swipe out, then leaves altogether so + // it can't sit over Today's widgets and Edit button (#25). Only the bar; the Side Bar dock is beside Today. + val dockStepsAsideForToday = todayMode && firstHome > 0 && geometry.horizontalDock + val dockAwayForToday by remember(dockStepsAsideForToday, nativePager) { + derivedStateOf { dockStepsAsideForToday && nativePager.currentPage + nativePager.currentPageOffsetFraction <= .02f } + } + if (!dockAwayForToday) Box((if (geometry.horizontalDock) (if (hinge?.active == true && hinge.vertical) // Half folded like a book: the bar sits centered on the trailing half, off the hinge. Modifier.align(Alignment.BottomEnd).padding(end = ((contentWidth / 2 - dockBarWidth) / 2).coerceAtLeast(0.dp)) else if (geometry.dockBesideRail) @@ -809,6 +815,12 @@ fun LauncherScreen( .width(dockBarWidth).height(geometry.dockBarHeight.dp) else Modifier.align(railTop(state.leftHanded)).railEdge(state.leftHanded, 12.dp).offset(y = dockTopShown.dp) .width(preset.dockWidth.dp).height(dockHeightShown.dp)).graphicsLayer { + if (dockStepsAsideForToday) { + // Read here, not in composition, so following the swipe doesn't recompose the screen. + val towardToday = (1f - nativePager.currentPage - nativePager.currentPageOffsetFraction).coerceIn(0f, 1f) + alpha = 1f - towardToday + translationY = towardToday * size.height * .6f + } // Composite the stationary dock independently of the shared pager layer (not while magnifying: it would clip). compositingStrategy = if (state.dockMagnify) androidx.compose.ui.graphics.CompositingStrategy.Auto else androidx.compose.ui.graphics.CompositingStrategy.Offscreen diff --git a/app/src/main/java/com/mccal/folio/TodayView.kt b/app/src/main/java/com/mccal/folio/TodayView.kt index b5e8a98..201b05e 100644 --- a/app/src/main/java/com/mccal/folio/TodayView.kt +++ b/app/src/main/java/com/mccal/folio/TodayView.kt @@ -67,7 +67,7 @@ internal fun TodayView(state: LauncherState, widgets: WidgetController, modifier // Search capsule val ink = LocalHomeInk.current Row(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(Color.White.copy(alpha = if (ink.dark) .5f else .16f)) - .clickable(onClickLabel = "Search", onClick = onSearch).padding(horizontal = 14.dp, vertical = 11.dp), + .clickable(onClickLabel = stringResource(R.string.search), onClick = onSearch).padding(horizontal = 14.dp, vertical = 11.dp), verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Rounded.Search, null, tint = ink.secondary, modifier = Modifier.size(20.dp)) Spacer(Modifier.width(8.dp)) From 00b64f3111b98c1b3cf6824f6d74465b3d2b28b0 Mon Sep 17 00:00:00 2001 From: McCal-Codes <175259256+McCal-Codes@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:41:45 -0400 Subject: [PATCH 3/7] Icons follow a theme that changes them (#19) Folio kept an app's icon until its package, the screen density, language or dark mode changed. A theme like Good Lock's Theme Park swaps icons without any of those, so the old icons stayed. The icon cache now also follows Android's count of theme and overlay changes, which folding and rotating don't touch, and Icons & Side Bar has Refresh Icons for anything that still slips through (in English and Chinese). --- .../com/mccal/folio/CustomizationSheet.kt | 2 ++ .../java/com/mccal/folio/LauncherModel.kt | 17 ++++++++++++++-- app/src/main/res/values-b+zh+Hans/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 2 ++ .../java/com/mccal/folio/IconRefreshTest.kt | 20 +++++++++++++++++++ 5 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/mccal/folio/IconRefreshTest.kt diff --git a/app/src/main/java/com/mccal/folio/CustomizationSheet.kt b/app/src/main/java/com/mccal/folio/CustomizationSheet.kt index 97d4a0a..7cd7888 100644 --- a/app/src/main/java/com/mccal/folio/CustomizationSheet.kt +++ b/app/src/main/java/com/mccal/folio/CustomizationSheet.kt @@ -407,6 +407,8 @@ internal fun CustomizationSheet(state: LauncherState, initiallyWide: Boolean, mo state.iconPack, { IconPacks.clear(); model.setIconPack(it) }, tag = "icon-pack") CardNote(stringResource(R.string.icon_packs_and_themes_are_made_by_indepe)) if (packs.isEmpty()) CardNote(stringResource(R.string.install_any_icon_pack_made_for_nova_styl)) + CardAction(stringResource(R.string.refresh_icons), onClick = model::reloadIcons) + CardNote(stringResource(R.string.refresh_icons_note)) // Live Clock and Calendar: the app's own icon, or live icons that match the others, or always light/dark. IosMenuRow(stringResource(R.string.clock_calendar), listOf("OFF" to stringResource(R.string.app_icons_2), "AUTO" to stringResource(R.string.live_automatic), "LIGHT" to stringResource(R.string.live_light), "DARK" to stringResource(R.string.live_dark)), if (state.liveIcons) state.liveIconLook else "OFF", diff --git a/app/src/main/java/com/mccal/folio/LauncherModel.kt b/app/src/main/java/com/mccal/folio/LauncherModel.kt index f27467a..bc066c1 100644 --- a/app/src/main/java/com/mccal/folio/LauncherModel.kt +++ b/app/src/main/java/com/mccal/folio/LauncherModel.kt @@ -338,6 +338,8 @@ class LauncherModel(application: Application) : AndroidViewModel(application) { // Accessed only in the serialized IO refresh. Returning Home reuses existing bitmaps. private val iconCache = mutableMapOf() private var iconConfiguration = "" + /** Set by [reloadIcons]; the refresh that follows drops every cached icon. */ + @Volatile private var iconsStale = false internal var completedRefreshes = 0 private set private val callback = object : LauncherApps.Callback() { @@ -375,6 +377,9 @@ class LauncherModel(application: Application) : AndroidViewModel(application) { init { launcherApps.registerCallback(callback); refresh(); FolioSettingsBridge.liveModel = java.lang.ref.WeakReference(this) } + /** Settings › Refresh Icons: for a theme app that changes icons without telling launchers (#19). */ + fun reloadIcons() { iconsStale = true; IconPacks.clear(); refresh() } + fun refresh(invalidatedPackage: String? = null, user: UserHandle = Process.myUserHandle()) { invalidatedPackage?.let { invalidatedPackages += userManager.getSerialNumberForUser(user) to it } if (refreshing) { refreshPending = true; return } @@ -385,11 +390,11 @@ class LauncherModel(application: Application) : AndroidViewModel(application) { invalidatedPackages.clear() removedPackages.clear() val resources = getApplication().resources - val configuration = resources.configuration.let { "${it.densityDpi}|${it.locales.toLanguageTags()}|${it.uiMode}" } + val configuration = resources.configuration.let { "${it.densityDpi}|${it.locales.toLanguageTags()}|${it.uiMode}|${assetsSequence(it.toString())}" } viewModelScope.launch { try { val apps = withContext(Dispatchers.IO) { - if (configuration != iconConfiguration) { iconCache.clear(); iconConfiguration = configuration } + if (configuration != iconConfiguration || iconsStale) { iconCache.clear(); iconConfiguration = configuration; iconsStale = false } iconCache.keys.removeAll { key -> parseProfileAppId(key)?.let { identity -> val serial = identity.userSerial ?: userManager.getSerialNumberForUser(Process.myUserHandle()) serial to (ComponentName.unflattenFromString(identity.component)?.packageName ?: "") in invalidated @@ -1428,3 +1433,11 @@ val LauncherState.glassTintAmount: Float get() = if (tintedGlass) .56f * glassTi /** Reduce Transparency: nearly solid widgets, Side Bar and dock, with a clearer edge (the saved values stay as they are). */ fun LauncherState.withSolidGlass(): LauncherState = copy(widgetGlass = maxOf(widgetGlass, .9f), glassOutline = maxOf(glassOutline, .45f), statusStyle = statusStyle.copy(railGlass = maxOf(statusStyle.railGlass, .9f))) + +/** + * Android counts changes to installed themes and overlays (Samsung's Theme Park among them) in the configuration's + * assets sequence. It isn't public API, but the configuration prints it as "as.N", so a theme that swaps icons without + * a package change still clears the icon cache, while folding and rotating don't. 0 when it isn't there. + */ +internal fun assetsSequence(configuration: String): Int = + Regex("""\bas\.(\d+)""").find(configuration)?.groupValues?.get(1)?.toIntOrNull() ?: 0 diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 5ece3c9..cbfcbb9 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -1356,4 +1356,6 @@ 功能 在折痕处分开 · 建议和自动更正 · 六种语言 · 剪贴板历史 · 快捷短语 · 不申请任何权限 Keyd 是一个单独的 App,因为 Android 要求键盘必须是独立的输入法。你的代码已把它的源添加到 Market 中,所以可以直接获取。它不申请任何权限,连网络权限也没有。 + 刷新图标 + 如果主题应用更改了图标,而 Folio 仍显示旧图标,可在这里重新载入。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 868879b..e8703f0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1398,4 +1398,6 @@ What it does Splits around the crease · suggestions and autocorrect · six languages · clipboard history · shortcuts · no permissions at all Keyd is a separate app, because Android needs a keyboard to be its own input method. Your code added its source to the Market, so it\'s there to get, and it asks for no permissions at all, not even the internet. + Refresh Icons + If a theme app changed your icons and Folio still shows the old ones, this loads them again. diff --git a/app/src/test/java/com/mccal/folio/IconRefreshTest.kt b/app/src/test/java/com/mccal/folio/IconRefreshTest.kt new file mode 100644 index 0000000..a2e41d0 --- /dev/null +++ b/app/src/test/java/com/mccal/folio/IconRefreshTest.kt @@ -0,0 +1,20 @@ +package com.mccal.folio + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** #19: a theme that swaps icons (Theme Park) changes the configuration's assets sequence, and nothing else Folio watched. */ +class IconRefreshTest { + @Test fun `reads the assets sequence from the configuration's text`() { + // As Configuration.toString prints it on Android 17, trimmed. + assertEquals(42, assetsSequence("{1.0 310mcc260mnc [en_US] ldltr sw561dp w561dp h839dp 420dpi nrml long port finger -keyb/v/h -nav/h winConfig={ } s.8 fontWeightAdjustment=0 as.42}")) + } + + @Test fun `is 0 when the configuration doesn't say`() { + assertEquals(0, assetsSequence("{1.0 ?mcc?mnc [en_US] ldltr sw561dp}")) + } + + @Test fun `isn't fooled by other fields that end in as`() { + assertEquals(0, assetsSequence("{1.0 has.3 alias.7}")) + } +} From 8924f8e14bea1a989be5fad71b8e9be07e815fdf Mon Sep 17 00:00:00 2001 From: McCal-Codes <175259256+McCal-Codes@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:41:46 -0400 Subject: [PATCH 4/7] Every Settings page has a way back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unfolded and upright, Settings shows its list beside the page only when there's room. Without it, a top-level page had just the sidebar button, which doesn't read as a way back. It now shows the sidebar button and "‹ Folio" together. With the list on screen, or in three columns, there is still only Done. --- .../com/mccal/folio/CustomizationSheet.kt | 22 +++++++++++-------- .../java/com/mccal/folio/HardcodedTextTest.kt | 2 +- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/mccal/folio/CustomizationSheet.kt b/app/src/main/java/com/mccal/folio/CustomizationSheet.kt index 7cd7888..8a779af 100644 --- a/app/src/main/java/com/mccal/folio/CustomizationSheet.kt +++ b/app/src/main/java/com/mccal/folio/CustomizationSheet.kt @@ -793,8 +793,10 @@ internal fun CustomizationSheet(state: LauncherState, initiallyWide: Boolean, mo divider() } Column(Modifier.weight(1f).fillMaxHeight().padding(horizontal = 20.dp)) { - // With the list still on screen there's nothing for Back to reveal, so the bar keeps only Done. - SettingsNavBar(if (threeColumns) null else nestedBackLabel, onBack, onClose, + // With the list still on screen there's nothing for Back to reveal, so the bar keeps only Done. Without + // it, every page but the first gets a way back, not only the sidebar button, which doesn't read as one. + SettingsNavBar(if (threeColumns) null else nestedBackLabel + ?: if (!tiled && page != CustomizationPage.OVERVIEW) stringResource(R.string.folio) else null, onBack, onClose, leading = if (tiled) null else ({ SidebarButton { sidebarOpen = !sidebarOpen } })) if (page != CustomizationPage.OVERVIEW) SettingsLargeTitle(title) Column(Modifier.weight(1f).edgeFade(bodyScroll).verticalScroll(bodyScroll).padding(bottom = 20.dp), horizontalAlignment = Alignment.CenterHorizontally) { @@ -846,14 +848,16 @@ internal fun CustomizationSheet(state: LauncherState, initiallyWide: Boolean, mo } @Composable private fun SettingsNavBar(backLabel: String?, onBack: () -> Unit, onClose: () -> Unit, leading: (@Composable () -> Unit)? = null) { - // iOS navigation bar: "‹ Back" on sub-pages (or the sidebar button), Done on the right. + // iOS navigation bar: the sidebar button and "‹ Back" on the left, Done on the right. Box(Modifier.fillMaxWidth().heightIn(min = 44.dp)) { - if (backLabel == null && leading != null) Box(Modifier.align(Alignment.CenterStart)) { leading() } - if (backLabel != null) Row(Modifier.align(Alignment.CenterStart).clip(RoundedCornerShape(10.dp)) - .clickable(onClick = onBack).padding(vertical = 8.dp, horizontal = 2.dp).testTag("customization-back"), - verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Rounded.ChevronLeft, null, tint = IosBlue, modifier = Modifier.size(28.dp)) - Text(backLabel, color = IosBlue, fontSize = 17.sp) + Row(Modifier.align(Alignment.CenterStart), verticalAlignment = Alignment.CenterVertically) { + leading?.invoke() + if (backLabel != null) Row(Modifier.clip(RoundedCornerShape(10.dp)) + .clickable(onClick = onBack).padding(vertical = 8.dp, horizontal = 2.dp).testTag("customization-back"), + verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.ChevronLeft, null, tint = IosBlue, modifier = Modifier.size(28.dp)) + Text(backLabel, color = IosBlue, fontSize = 17.sp) + } } Text(stringResource(R.string.done), color = IosBlue, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.align(Alignment.CenterEnd).clip(RoundedCornerShape(10.dp)).clickable(onClick = onClose) diff --git a/app/src/test/java/com/mccal/folio/HardcodedTextTest.kt b/app/src/test/java/com/mccal/folio/HardcodedTextTest.kt index d83b564..d54ae9d 100644 --- a/app/src/test/java/com/mccal/folio/HardcodedTextTest.kt +++ b/app/src/test/java/com/mccal/folio/HardcodedTextTest.kt @@ -56,5 +56,5 @@ class HardcodedTextTest { assertTrue("English in the manifest: $literal. Use @string/ so Android shows it translated.", literal.isEmpty()) } - private companion object { const val LIMIT = 222 } + private companion object { const val LIMIT = 221 } } From 454e7e3be77e8b972cd799b51002b45e3bf453b6 Mon Sep 17 00:00:00 2001 From: McCal-Codes <175259256+McCal-Codes@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:08:14 -0400 Subject: [PATCH 5/7] No more smeared, doubled pages after choosing Android's wallpaper (#12, #35) Switching Background to Android wallpaper rebuilt the screen with recreate(), which keeps the old window. When that window had first been made opaque (Folio's own background, the default), it stayed opaque to the screen even after asking for the wallpaper: the wallpaper never showed, and each frame of a swipe was drawn over the last, leaving copies of pages and icons behind. A fresh start of the app always looked right, which is why it came and went. The switch now starts the screen again with a new window, saving the choice on its own first so the new window is built from it. Folio also draws its own background whenever the window isn't really showing the wallpaper, so the worst case is the dunes, never a smear, and rebuilds the window once if the two ever disagree. The setup step that keeps your wallpaper takes the same path. Reproduced on a Fold8 every time (fresh install, switch, swipe) and gone after this change. --- .../java/com/mccal/folio/DuneWallpaper.kt | 33 ++++++++++++++++--- .../java/com/mccal/folio/LauncherModel.kt | 4 ++- .../java/com/mccal/folio/LauncherScreen.kt | 11 ++++++- .../main/java/com/mccal/folio/MainActivity.kt | 2 +- .../main/java/com/mccal/folio/SettingKeys.kt | 2 ++ .../java/com/mccal/folio/SetupChecklist.kt | 2 +- 6 files changed, 45 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/mccal/folio/DuneWallpaper.kt b/app/src/main/java/com/mccal/folio/DuneWallpaper.kt index 7972247..686c031 100644 --- a/app/src/main/java/com/mccal/folio/DuneWallpaper.kt +++ b/app/src/main/java/com/mccal/folio/DuneWallpaper.kt @@ -1,5 +1,6 @@ package com.mccal.folio +import androidx.core.content.edit import kotlinx.coroutines.flow.conflate import android.service.wallpaper.WallpaperService @@ -235,7 +236,7 @@ internal fun SystemWallpaperParallax(pager: androidx.compose.foundation.pager.Pa * * Turning it on needs the window itself, not just a flag on it: `android:windowShowWallpaper` belongs to the theme * the window was built from, and a window built opaque shows the wallpaper only through the relayout before covering - * it again, which looked like the wallpaper flashing up and vanishing. The setting is saved before this is called and + * it again, which looked like the wallpaper flashing up and vanishing. The setting is saved here, at once, and * [MainActivity] picks the wallpaper theme from it, so starting the screen again is what actually turns it on. * * Turning it off needs no restart: an opaque background over the same window hides the wallpaper, as it always did. @@ -251,8 +252,11 @@ internal tailrec fun android.content.Context.asActivity(): android.app.Activity? } internal fun android.app.Activity.applyWallpaperWindow(system: Boolean) { - val showing = window.attributes.flags and android.view.WindowManager.LayoutParams.FLAG_SHOW_WALLPAPER != 0 - if (system && !showing) { recreate(); return } + // Saved here and on its own before the screen starts again: the full state save can be held back (a first run + // still loading its apps), and the new window is built from what's saved. apply() is enough, since the new screen + // runs in this process and reads the same preferences in memory. + getSharedPreferences(SettingKeys.PREFS, 0).edit { putBoolean(SettingKeys.SYSTEM_WALLPAPER, system) } + if (system && !showsWallpaper) { startAgain(); return } val background = if (system) android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT) else obtainStyledAttributes(R.style.Theme_Duo, intArrayOf(android.R.attr.windowBackground)).let { it.getDrawable(0).also { _ -> it.recycle() } } window.setBackgroundDrawable(background) @@ -260,8 +264,27 @@ internal fun android.app.Activity.applyWallpaperWindow(system: Boolean) { else window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_SHOW_WALLPAPER) } +/** + * A new instance of this screen, with a new window. Not `recreate()`: Android keeps the old window across it, and a + * window first made opaque stays opaque to the screen even after it asks for the wallpaper, so the wallpaper never + * showed and every swipe piled onto the last frame (#12, #35). The same intent, so Settings opens again on top. + */ +private fun android.app.Activity.startAgain() { + startActivity(android.content.Intent(intent).addFlags( + android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK)) + finish() +} + +/** Whether this window actually shows Android's wallpaper behind it, whatever the setting says. */ +internal val android.app.Activity.showsWallpaper: Boolean + get() = window.attributes.flags and android.view.WindowManager.LayoutParams.FLAG_SHOW_WALLPAPER != 0 + /** Whether Home shows Android's wallpaper (read straight from saved state: needed before the activity's window exists). */ internal fun usesSystemWallpaper(context: android.content.Context): Boolean = runCatching { - org.json.JSONObject(context.getSharedPreferences(SettingKeys.PREFS, 0).getString(SettingKeys.STATE, "{}") ?: "{}") - .optBoolean("systemWallpaper", false) + val prefs = context.getSharedPreferences(SettingKeys.PREFS, 0) + if (prefs.contains(SettingKeys.SYSTEM_WALLPAPER)) return@runCatching prefs.getBoolean(SettingKeys.SYSTEM_WALLPAPER, false) + org.json.JSONObject(prefs.getString(SettingKeys.STATE, "{}") ?: "{}").optBoolean("systemWallpaper", false) }.getOrDefault(false) + +/** Once per process: the window is rebuilt at most once to match the setting, so a mismatch can never loop. */ +internal object WallpaperWindowRepair { var tried = false } diff --git a/app/src/main/java/com/mccal/folio/LauncherModel.kt b/app/src/main/java/com/mccal/folio/LauncherModel.kt index bc066c1..8c3cb4f 100644 --- a/app/src/main/java/com/mccal/folio/LauncherModel.kt +++ b/app/src/main/java/com/mccal/folio/LauncherModel.kt @@ -1151,7 +1151,9 @@ class LauncherModel(application: Application) : AndroidViewModel(application) { editor.putString("state_v7_backup", legacyRaw) if (legacyRaw != null && sourceSchema < 9 && !prefs.contains("state_v8_backup")) editor.putString("state_v8_backup", legacyRaw) - editor.putString("state", data.toString()).putBoolean("initialized", true).apply() + editor.putString("state", data.toString()).putBoolean("initialized", true) + // Kept beside the state so a restore or theme import that changes it chooses the right window next time. + .putBoolean(SettingKeys.SYSTEM_WALLPAPER, s.systemWallpaper).apply() } private fun load(): LauncherState = runCatching { diff --git a/app/src/main/java/com/mccal/folio/LauncherScreen.kt b/app/src/main/java/com/mccal/folio/LauncherScreen.kt index 6af83a0..4dd0a6f 100644 --- a/app/src/main/java/com/mccal/folio/LauncherScreen.kt +++ b/app/src/main/java/com/mccal/folio/LauncherScreen.kt @@ -152,6 +152,13 @@ fun LauncherScreen( var libraryQuery by rememberSaveable { mutableStateOf("") } var pinQuery by rememberSaveable { mutableStateOf("") } val launcherActivity = androidx.activity.compose.LocalActivity.current as MainActivity + // The setting says Android's wallpaper but the window was built without it: rebuild it once to match. + LaunchedEffect(state.systemWallpaper) { + if (state.systemWallpaper && !launcherActivity.showsWallpaper && !WallpaperWindowRepair.tried) { + WallpaperWindowRepair.tried = true + launcherActivity.applyWallpaperWindow(true) + } + } val launcherRootView = LocalView.current.rootView val marketSession = remember(model) { MarketSession(launcherActivity, ModelLauncher(model)) } // Package Safe Mode: runs as Home starts, so a package that crashed Folio while it was being applied is turned off @@ -536,7 +543,9 @@ fun LauncherScreen( val panelsOn = FeatureScopes.on(state.featureScopes, "appPanels", state.appPanels, screenFor(panelWide)) if (panelsOn && !homeEdit.active) { app: AppEntry -> haptic.performHapticFeedback(HapticFeedbackType.ContextClick); overlays.panel = app.id } else null }) { - if (!state.systemWallpaper) DuneWallpaper() + // Folio's background unless Android's wallpaper is really behind the window: a see-through window with + // nothing behind it shows every earlier frame (#12, #35), so the worst case is the dunes, never a smear. + if (!state.systemWallpaper || !launcherActivity.showsWallpaper) DuneWallpaper() else if (state.wallpaperMotion) SystemWallpaperParallax(nativePager) // iOS "dark appearance dims wallpaper". val dim by androidx.compose.animation.core.animateFloatAsState(if (state.dimWallpaperDark && appearance.dark) .3f else 0f, label = "wallpaper dim") diff --git a/app/src/main/java/com/mccal/folio/MainActivity.kt b/app/src/main/java/com/mccal/folio/MainActivity.kt index 065f174..30a09d8 100644 --- a/app/src/main/java/com/mccal/folio/MainActivity.kt +++ b/app/src/main/java/com/mccal/folio/MainActivity.kt @@ -81,7 +81,7 @@ class MainActivity : ComponentActivity() { private var recreatingShadeSetup = false override fun onCreate(savedInstanceState: Bundle?) { - // The wallpaper theme must be chosen before the window exists (switching it later recreates the activity). + // The wallpaper theme must be chosen before the window exists (switching to it starts the screen again). if (usesSystemWallpaper(this)) setTheme(R.style.Theme_Duo_Wallpaper) super.onCreate(savedInstanceState) setupExperience = SetupExperience(this) diff --git a/app/src/main/java/com/mccal/folio/SettingKeys.kt b/app/src/main/java/com/mccal/folio/SettingKeys.kt index b835653..23bb56a 100644 --- a/app/src/main/java/com/mccal/folio/SettingKeys.kt +++ b/app/src/main/java/com/mccal/folio/SettingKeys.kt @@ -4,6 +4,8 @@ package com.mccal.folio internal object SettingKeys { const val PREFS = "launcher" const val STATE = "state" + /** Whether Home's window shows Android's wallpaper, saved on its own and at once (see applyWallpaperWindow). */ + const val SYSTEM_WALLPAPER = "systemWallpaperWindow" const val DOCK = "dock" const val LEFT_HANDED = "leftHanded" const val DOCK_EVERYWHERE = "dockEverywhere" diff --git a/app/src/main/java/com/mccal/folio/SetupChecklist.kt b/app/src/main/java/com/mccal/folio/SetupChecklist.kt index 0bab232..27c49ba 100644 --- a/app/src/main/java/com/mccal/folio/SetupChecklist.kt +++ b/app/src/main/java/com/mccal/folio/SetupChecklist.kt @@ -74,7 +74,7 @@ internal fun rememberSetupSteps(isDefaultHome: Boolean, onMakeDefault: () -> Uni notifications.isNotificationPolicyAccessGranted, false, context.getString(R.string.allow)) { open(Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS)) }, SetupStep(Icons.Rounded.Wallpaper, context.getString(R.string.keep_your_wallpaper), context.getString(R.string.coming_from_samsung_s_or_another_launche), - systemWallpaper, false, context.getString(R.string.use)) { onSystemWallpaper(true); context.asActivity()?.recreate() }, + systemWallpaper, false, context.getString(R.string.use)) { onSystemWallpaper(true); context.asActivity()?.applyWallpaperWindow(true) }, SetupStep(Icons.Rounded.Assistant, context.getString(R.string.folio_as_your_digital_assistant), context.getString(R.string.holding_the_side_key_opens_folio_s_picke), AssistPickerActivity.isDefaultAssistant(context), false, context.getString(R.string.choose)) { open(AssistPickerActivity.settingsIntent()) }, From 71688d8e214374f44d06c4b3f249ddfd32327abd Mon Sep 17 00:00:00 2001 From: McCal-Codes <175259256+McCal-Codes@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:35:15 -0400 Subject: [PATCH 6/7] The Preview bar no longer covers the App Library Before Folio is the Home app, the Preview bar sits above the page dots, and the space kept for them was a guess (88dp). Unfolded and sideways, the bar is taller than that and sat over the bottom of the App Library's panel. The controls are measured now, and Home, the App Library and the dock keep clear of them with a small gap. As the Home app nothing moves: the space stays 44dp unless the dots are taller, so no one's automatic rows shrink. --- app/src/main/java/com/mccal/folio/LauncherScreen.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/mccal/folio/LauncherScreen.kt b/app/src/main/java/com/mccal/folio/LauncherScreen.kt index 4dd0a6f..e651dbf 100644 --- a/app/src/main/java/com/mccal/folio/LauncherScreen.kt +++ b/app/src/main/java/com/mccal/folio/LauncherScreen.kt @@ -644,7 +644,13 @@ fun LauncherScreen( val pagerWidth = if (geometry.horizontalDock && !geometry.dockBesideRail) maxWidth else maxWidth - preset.dockWidth.dp - 28.dp val leftColumnOrigin = (maxWidth / 2f - geometry.gridWidth.dp) / 2f - 16.dp val homeStride = panelWidth - leftColumnOrigin - val bottomSpace = (if (isDefaultHome) 44.dp else 88.dp) + if (geometry.horizontalDock) (geometry.dockBarHeight + 16f).dp else 0.dp + // The page controls under Home (and the Preview bar before Folio is the Home app), measured: the old guess + // of 44dp, 88dp with the Preview bar, left the App Library's panel under the Preview bar sideways. + var bottomControlsHeight by remember { mutableStateOf(0.dp) } + // As the Home app it stays 44dp unless the dots are taller, so no one's automatic rows shrink; the Preview + // bar gets a clear gap above it. + val controlsSpace = if (isDefaultHome) maxOf(44.dp, bottomControlsHeight) else maxOf(88.dp, bottomControlsHeight + 12.dp) + val bottomSpace = controlsSpace + if (geometry.horizontalDock) (geometry.dockBarHeight + 16f).dp else 0.dp val workspaceMotion = if (geometry.expanded) remember(firstHome, visibleHomePages, pagerWidth, homeStride, density) { WorkspacePageMotion(firstHome, visibleHomePages, with(density) { pagerWidth.toPx() }, with(density) { homeStride.toPx() }) } else null @@ -820,7 +826,7 @@ fun LauncherScreen( .padding(start = if (state.leftHanded) 0.dp else ((pagerWidth + 16.dp - dockBarWidth) / 2).coerceAtLeast(0.dp), end = if (state.leftHanded) ((pagerWidth + 16.dp - dockBarWidth) / 2).coerceAtLeast(0.dp) else 0.dp) else Modifier.align(Alignment.BottomCenter)) - .padding(bottom = (if (isDefaultHome) 44 else 88).dp + 8.dp) + .padding(bottom = controlsSpace + 8.dp) .width(dockBarWidth).height(geometry.dockBarHeight.dp) else Modifier.align(railTop(state.leftHanded)).railEdge(state.leftHanded, 12.dp).offset(y = dockTopShown.dp) .width(preset.dockWidth.dp).height(dockHeightShown.dp)).graphicsLayer { @@ -848,6 +854,7 @@ fun LauncherScreen( // page's width is held out of the row, leaving it centred on Home on both screens. val besideHome = if (geometry.expanded) panelWidth.coerceAtLeast(0.dp) else 0.dp Column(Modifier.align(if (state.leftHanded) Alignment.BottomEnd else Alignment.BottomStart).width(pagerWidth) + .onSizeChanged { bottomControlsHeight = with(density) { it.height.toDp() } } .padding(start = if (state.leftHanded) 0.dp else besideHome + 16.dp, end = if (state.leftHanded) besideHome + 16.dp else 0.dp, bottom = 6.dp), horizontalAlignment = Alignment.CenterHorizontally) { if (!isDefaultHome && !homeEdit.active && !drag.active) PreviewBar(onUseAsHome = { sheet = ""; onMakeDefault() }, From 6036c249e26bc2f3e6ca96895a8b86d8551c6094 Mon Sep 17 00:00:00 2001 From: McCal-Codes <175259256+McCal-Codes@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:47:44 -0400 Subject: [PATCH 7/7] Home's background costs the GPU a third less while swiping Folio's dunes never move, but during a swipe everything above them does, so Android redrew the whole background every frame: a full-screen gradient, three dunes and 29 anti-aliased strokes. It's drawn once into its own layer now and reused, one texture copy a frame. Measured on a Fold8 (unfolded, 120 Hz, 20 swipes, twice each): GPU time per frame median 7.0 to 4.6-4.9 ms, 90th percentile 9.7 to 6.9-7.5 ms. --- app/src/main/java/com/mccal/folio/DuneWallpaper.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/mccal/folio/DuneWallpaper.kt b/app/src/main/java/com/mccal/folio/DuneWallpaper.kt index 686c031..40e4804 100644 --- a/app/src/main/java/com/mccal/folio/DuneWallpaper.kt +++ b/app/src/main/java/com/mccal/folio/DuneWallpaper.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.drawscope.CanvasDrawScope import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.unit.Density @@ -46,7 +47,12 @@ internal fun DuneWallpaper(modifier: Modifier = Modifier) { val photo = produceState(initialValue = initial, key1 = context, key2 = revision) { value = withContext(Dispatchers.IO) { loadLauncherBackground(context) } }.value - Canvas(Modifier.fillMaxSize().then(modifier)) { drawLauncherBackground(photo?.asImageBitmap(), palette.dark) } + // Drawn once into its own layer and reused: the background never moves, but during a swipe everything above it + // does, so it was redrawn every frame (a gradient, three dunes and 29 strokes, full screen) and the GPU missed + // frames. A cached layer is one texture copy a frame instead. + Canvas(Modifier.fillMaxSize().then(modifier).graphicsLayer { + compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen + }) { drawLauncherBackground(photo?.asImageBitmap(), palette.dark) } } internal fun DrawScope.drawLauncherBackground(photo: ImageBitmap?, dark: Boolean = false) {