Skip to content
24 changes: 15 additions & 9 deletions app/src/main/java/com/mccal/folio/CustomizationSheet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -791,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) {
Expand Down Expand Up @@ -844,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)
Expand Down
41 changes: 35 additions & 6 deletions app/src/main/java/com/mccal/folio/DuneWallpaper.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.mccal.folio

import androidx.core.content.edit
import kotlinx.coroutines.flow.conflate

import android.service.wallpaper.WallpaperService
Expand Down Expand Up @@ -31,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
Expand All @@ -45,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) {
Expand Down Expand Up @@ -235,7 +242,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.
Expand All @@ -251,17 +258,39 @@ 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 }
Comment on lines +264 to +265

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve an unsaved wallpaper choice across restart

If the user selects Android wallpaper while the initial app refresh/migration is still running, LauncherModel.setSystemWallpaper(true) cannot persist the JSON state because persist() returns while needsMigration is true. This restart then destroys that model; the replacement model decodes systemWallpaper=false from the unchanged JSON, so DuneWallpaper covers the newly themed window and the next refresh writes false back over this standalone preference. The separately saved value must also seed the replacement LauncherState, or the restart must wait until the state itself is durable.

Useful? React with 👍 / 👎.

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)
if (system) window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SHOW_WALLPAPER)
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))
Comment on lines +279 to +280

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Carry the Settings destination into the new task

When Android wallpaper is enabled from Settings opened through Home's in-app Customize path, the activity's intent is still the ordinary HOME intent; the open sheet and customizationPage exist only in Compose state. Starting that cloned intent with CLEAR_TASK destroys those values, and the replacement activity lands on Home rather than reopening Settings as the comment claims. Preserve the Settings destination/page in the replacement intent or another durable state before clearing the task.

Useful? React with 👍 / 👎.

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 }
10 changes: 8 additions & 2 deletions app/src/main/java/com/mccal/folio/HomeWorkspace.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)),
Expand Down
21 changes: 18 additions & 3 deletions app/src/main/java/com/mccal/folio/LauncherModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, AppEntry>()
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() {
Expand Down Expand Up @@ -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() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate composed icon-pack results on refresh

When an icon pack is active, this clears IconPacks and reloads each AppEntry, but AppIcon retains its already-produced PackLookup because that state is keyed only by look.pack and app.id; both remain unchanged. Consequently, tapping the new Refresh Icons action continues drawing the old pack bitmap until the composable is recreated or the pack selection changes. Clear-style glyphs have the same problem and are not cleared at all. Include an observable icon revision in those producer keys and clear the related derived caches.

Useful? React with 👍 / 👎.


fun refresh(invalidatedPackage: String? = null, user: UserHandle = Process.myUserHandle()) {
invalidatedPackage?.let { invalidatedPackages += userManager.getSerialNumberForUser(user) to it }
if (refreshing) { refreshPending = true; return }
Expand All @@ -385,11 +390,11 @@ class LauncherModel(application: Application) : AndroidViewModel(application) {
invalidatedPackages.clear()
removedPackages.clear()
val resources = getApplication<Application>().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
Expand Down Expand Up @@ -1146,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 {
Expand Down Expand Up @@ -1428,3 +1435,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
Loading
Loading