From 947d98709c98d08677e52058c0c6bcdb9e2970c9 Mon Sep 17 00:00:00 2001 From: GothwadTech <295316515+GothwadTech@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:59:07 +0000 Subject: [PATCH 1/9] Fix trim-level dispatch, torn shared-file reads, and cold-start double scan Correctness & stability: - onTrimMemory treated UI_HIDDEN (20) and every background level (40/60/80) as a CRITICAL trim because they matched 'level >= RUNNING_CRITICAL' (15) first, and the TRIM_MEMORY_COMPLETE branch was unreachable dead code. Opening an app from the launcher used to kill the user's other background apps when aggressive trim was on. - Shared cross-process files (heartbeat, relaunch policy, self-heal) are now written atomically (temp + rename): a plain writeText truncates first, and the :watchdog reading in that window parsed an empty file as 'the launcher is dead' and yanked the screen back to HOME mid-movie. - The boot shield no longer silences audio while a known user app is in front; only boot-ad-like cases (stock launcher / our home / unknown foreground) are silenced. Lightweight: - AppRepository scans are serialised with a Mutex (MainActivity + TvLauncherFragment both scanned on cold start), artwork decoding capped at 6 concurrent ops, and only loaded cache names protect files from pruning. - Heartbeat writes moved off the main thread onto a dedicated daemon thread (rule #3); policy writes too. - DensityAdapter persists uiScale/font-scale prefs only on a real change. - PACKAGE_REMOVED no longer probes PackageManager for the already-gone package; dead MainActivity code (pass-through dispatchKeyEvent, empty setupNavigation) removed. - getInstalledPackages in the accessibility service runs on Dispatchers.IO. Tests: ProcessHeartbeatAtomicWriteTest locks in the atomic-replace write. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- CHANGELOG.md | 32 ++++- .../gothwad/launcher/GothwadApplication.kt | 35 +++++- .../java/com/gothwad/launcher/MainActivity.kt | 24 ++-- .../gothwad/launcher/data/AppRepository.kt | 115 +++++++++++------- .../gothwad/launcher/data/ProcessHeartbeat.kt | 70 +++++++++-- .../launcher/service/BootShieldService.kt | 20 ++- .../service/LauncherAccessibilityService.kt | 6 +- .../com/gothwad/launcher/ui/DensityAdapter.kt | 9 +- .../data/ProcessHeartbeatAtomicWriteTest.kt | 48 ++++++++ docs/PERFORMANCE.md | 7 +- docs/TESTING.md | 15 ++- 11 files changed, 291 insertions(+), 90 deletions(-) create mode 100644 app/src/test/java/com/gothwad/launcher/data/ProcessHeartbeatAtomicWriteTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index dc10e88..8d1569b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,31 @@ is cut. launcher silently. - **`BootReceiver` no longer runs the guard loop inside `goAsync()`** (ANR on every boot); it starts the service and returns. +- **`onTrimMemory` dispatched nearly every level as a *critical* trim.** The framework's levels + are three groups, not one "higher is worse" scale: `UI_HIDDEN` (20) is "our UI just went away" + and `BACKGROUND/MODERATE/COMPLETE` (40/60/80) are "we are backgrounded", but the old + `level >= RUNNING_CRITICAL` (15) test caught them all first — so with aggressive trim enabled, + simply opening Netflix from the launcher killed the user's other background apps (their music + included). The `TRIM_MEMORY_COMPLETE` branch was unreachable dead code for the same reason. + Now: 15 = critical, 10 = gentle, 20 = no-op (the user is *using* other apps), 40/60/80 = + gentle trim while backgrounded. +- **Heartbeat/policy/self-heal files are written atomically** (temp file + `rename`). A plain + `writeText` truncates first, so the `:watchdog` polling mid-write could read an empty file, + parse `0`, and conclude "the launcher is dead" — yanking the TV back to HOME mid-movie. The + shared-file writes also moved off the main thread onto a dedicated heartbeat thread + (project rule #3: no blocking work on the main thread, not even "a few bytes"). +- **The boot shield no longer silences user media.** The 45–90 s guard window silenced *any* + active audio every 2 s — including a Netflix stream or music the user deliberately started + after boot. It now stays silent unless the audio plausibly belongs to a boot ad (stock + launcher in front, our home screen in front, or no identifiable foreground). +- **Cold start no longer runs two full app scans concurrently.** `MainActivity` and + `TvLauncherFragment` both scan; the scans are serialised now, so the second caller waits and + hits the memory-cache fast path instead of doubling PackageManager + bitmap work and racing + the cache-prune step. Per-app artwork decoding is capped at 6 concurrent ops (was unbounded + `async(IO)` over every installed package — a needless memory spike on 2 GB boxes). +- **`PACKAGE_REMOVED` broadcasts no longer do PackageManager lookups** on the main thread for a + package that is already gone; corrupt app-art cache files are no longer protected from pruning + by a cache-name entry for a bitmap that never loaded. ### Fixed — security & privacy @@ -85,6 +110,8 @@ is cut. configs still decode (`ignoreUnknownKeys`); a test locks that in. - `MainActivity` no longer wrote an auto-category assignment for every new install — a JSON encode plus a DataStore write per scan, for data no screen read. +- Dead `MainActivity` code: a `dispatchKeyEvent` override that only called `super`, and an empty + `setupNavigation()` stub (NavHostFragment loads the start destination itself). - `CategoryAssigner` and its test, `layout_a11y_banner.xml`, 8 unused drawables, the orphaned `cd_vpn` content description and 4 banner strings. - Dead signing steps: `jarsigner` (produced v1-only signatures for the AAB) and `apksigner sign` @@ -94,6 +121,8 @@ is cut. ### Changed +- `DensityAdapter.setUiScale` / `setRespectSystemFontScale` persist only on a real change; both + run on every config emission, and re-writing the same value each time was pointless flash wear. - Wallpaper decoding is down-sampled to the physical screen size and only re-decodes when the source file changed; it used to re-decode the full-resolution JPEG on every config emission. - Status-bar clusters get focus/hover feedback via a `StateListDrawable` built from one helper. @@ -113,7 +142,8 @@ is cut. (minified), runs unit tests and lint, and publishes the debug APK plus reports as artifacts. - Composite actions `resolve-version` (monotonic version code from the version name) and `prepare-signing` (keystore from secrets, alias auto-detection, always-cleaned temp file). -- Unit tests: `ConfigSerializationTest` (5) and `LockSecurityTest` (10). +- Unit tests: `ConfigSerializationTest` (5), `LockSecurityTest` (10) and + `ProcessHeartbeatAtomicWriteTest` (3, locks in the atomic shared-file write). - Developer documentation under `docs/`: architecture, config reference, security, performance, CI & release, testing, troubleshooting, i18n, issue log, roadmap. - Issue and pull-request templates, `CONTRIBUTING.md`, and this changelog. diff --git a/app/src/main/java/com/gothwad/launcher/GothwadApplication.kt b/app/src/main/java/com/gothwad/launcher/GothwadApplication.kt index cc1c1ef..bd8e003 100644 --- a/app/src/main/java/com/gothwad/launcher/GothwadApplication.kt +++ b/app/src/main/java/com/gothwad/launcher/GothwadApplication.kt @@ -152,22 +152,47 @@ class GothwadApplication : Application() { } } + /** + * Dispatches the framework's memory-pressure levels onto the opt-in trim. + * + * The levels are **not** one monotonic "worse is higher" scale - they come in three + * groups (see `ComponentCallbacks2`): + * + * - 5/10/15 (`RUNNING_*`): the system is low on memory while our UI is in front. + * - 20 (`UI_HIDDEN`): our own UI just went away - the user is *using* other apps now. + * - 40/60/80 (`BACKGROUND`/`MODERATE`/`COMPLETE`): we are in the background and the + * system is reclaiming aggressively. + * + * The old code tested `level >= RUNNING_CRITICAL` first, so `UI_HIDDEN` (20) and every + * background level fell into the **critical** branch: with "aggressive trim" enabled, + * simply opening Netflix from the launcher killed all the user's other background apps + * (their music included) - issue #34 all over again. The `TRIM_MEMORY_COMPLETE` branch + * was also unreachable dead code for the same reason. + */ override fun onTrimMemory(level: Int) { super.onTrimMemory(level) when { + level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> { + // 40/60/80: launcher itself is backgrounded and memory is short. Gentle trim. + Log.i(TAG, "onTrimMemory: background reclaim (level=$level), triggering background trim") + AppLaunchTracker.performMemoryTrim(this, isCritical = false) + } + level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> { + // 20: our UI just disappeared. The user's other apps are exactly what they + // are using right now - never touch them here. + Log.d(TAG, "onTrimMemory: UI hidden (level=$level) - no trim") + } level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> { + // 15: genuine foreground memory crisis - the only case for a critical trim. Log.w(TAG, "onTrimMemory: TRIM_MEMORY_RUNNING_CRITICAL (level=$level), triggering critical trim") AppLaunchTracker.performMemoryTrim(this, isCritical = true) } level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> { + // 10: foreground pressure - gentle trim. Log.i(TAG, "onTrimMemory: TRIM_MEMORY_RUNNING_LOW (level=$level), triggering background trim") AppLaunchTracker.performMemoryTrim(this, isCritical = false) } - level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> { - // Background process cleanup when launcher itself is backgrounded - Log.d(TAG, "onTrimMemory: TRIM_MEMORY_COMPLETE (level=$level)") - AppLaunchTracker.performMemoryTrim(this, isCritical = false) - } + // 5 (RUNNING_MODERATE): plenty of headroom, do nothing. } } diff --git a/app/src/main/java/com/gothwad/launcher/MainActivity.kt b/app/src/main/java/com/gothwad/launcher/MainActivity.kt index b8201a2..9da9b4a 100644 --- a/app/src/main/java/com/gothwad/launcher/MainActivity.kt +++ b/app/src/main/java/com/gothwad/launcher/MainActivity.kt @@ -9,7 +9,6 @@ import android.content.pm.PackageManager import android.content.res.Configuration import android.os.Build import android.os.Bundle -import android.view.View import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat @@ -25,7 +24,6 @@ import com.gothwad.launcher.data.SelfHealGuard import com.gothwad.launcher.databinding.ActivityMainBinding import com.gothwad.launcher.ui.MainStatusBarController import com.gothwad.launcher.ui.tv.TvLauncherFragment -import android.view.KeyEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -46,12 +44,17 @@ class MainActivity : AppCompatActivity() { private val packageReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - val pkg = intent.data?.schemeSpecificPart - val launchable = pkg != null && ( + // Uninstall/update: no PM probe needed - the package is already gone or being + // replaced, and the rescan is what rebuilds the grid anyway. + if (intent.action == Intent.ACTION_PACKAGE_REMOVED) { + scheduleAppsRescan() + return + } + val pkg = intent.data?.schemeSpecificPart ?: return + val launchable = packageManager.getLeanbackLaunchIntentForPackage(pkg) != null || packageManager.getLaunchIntentForPackage(pkg) != null - ) - if (intent.action == Intent.ACTION_PACKAGE_REMOVED || launchable) { + if (launchable) { scheduleAppsRescan() } } @@ -156,7 +159,6 @@ class MainActivity : AppCompatActivity() { } setupA11yWatchdogReceiver() - setupNavigation() statusBarController = MainStatusBarController( activity = this, binding = binding, @@ -214,10 +216,6 @@ class MainActivity : AppCompatActivity() { statusBarController?.updateA11yStatus() } - private fun setupNavigation() { - // NavHostFragment loads tvLauncherFragment as startDestination from nav_graph.xml - } - private fun handleAppLaunch(app: AppEntry) { // App lock & hidden vault gate AppLockGate.evaluate( @@ -230,10 +228,6 @@ class MainActivity : AppCompatActivity() { } } - override fun dispatchKeyEvent(event: KeyEvent): Boolean { - return super.dispatchKeyEvent(event) - } - override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) com.gothwad.launcher.ui.DensityAdapter.apply(this) diff --git a/app/src/main/java/com/gothwad/launcher/data/AppRepository.kt b/app/src/main/java/com/gothwad/launcher/data/AppRepository.kt index 41f6faa..7ca29b1 100644 --- a/app/src/main/java/com/gothwad/launcher/data/AppRepository.kt +++ b/app/src/main/java/com/gothwad/launcher/data/AppRepository.kt @@ -11,6 +11,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import java.io.File @@ -37,6 +41,21 @@ object AppRepository { @Volatile private var memoryCache: List? = null + /** + * Serialises whole scans. `MainActivity` and `TvLauncherFragment` both scan on a cold + * start; without this the two full PackageManager + bitmap passes ran concurrently + * (doubling the work on the exact path this project optimises) and raced each other's + * cache-prune step. The loser now waits and hits the memory-cache fast path instead. + */ + private val scanMutex = Mutex() + + /** + * Caps how many apps are decoded/rasterised at once. Unbounded `async(IO)` over ~200 + * packages used to inflate hundreds of drawables concurrently - on a 2 GB box that is + * a needless memory spike during the scan. + */ + private val decodeSemaphore = Semaphore(6) + /** * Scans launchable apps. Banners and icons are pre-rasterized and cached to * disk as LOSSLESS WebP so subsequent cold starts only do disk decodes, not @@ -45,8 +64,10 @@ object AppRepository { * Returns the same List instance if the set of installed packages and their * timestamps didn't change, letting downstream collectors skip work. */ - suspend fun scan(context: Context): List = withContext(Dispatchers.IO) { - scanInternal(context) + suspend fun scan(context: Context): List = scanMutex.withLock { + withContext(Dispatchers.IO) { + scanInternal(context) + } } private suspend fun scanInternal(context: Context): List = coroutineScope { @@ -96,50 +117,54 @@ object AppRepository { val entries = byPkg.values.map { ri -> async(Dispatchers.IO) { - val pkg = ri.activityInfo.packageName - val (stamp, firstInstall) = meta[pkg] ?: (0L to 0L) - val ai = ri.activityInfo - - // Leanback banner (16:9, fixed height 180px is plenty for 1080p/4K TV grids) - val bannerName = "${pkg}_b_$stamp.webp" - validCacheNames.add(bannerName) - val banner = cachedBitmap(cacheDir, bannerName, Bitmap.Config.RGB_565) { - runCatching { - val d = ai.loadBanner(pm) ?: ai.applicationInfo.loadBanner(pm) - d?.let { - val w = 320 - val h = 180 - val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.RGB_565) - val canvas = android.graphics.Canvas(bmp) - it.setBounds(0, 0, w, h) - it.draw(canvas) - bmp - } - }.getOrNull() - } - - // App icon (only decoded/saved if there's no banner, or as square fallback) - val iconName = "${pkg}_i_$stamp.webp" - val icon = cachedBitmap(cacheDir, iconName, Bitmap.Config.ARGB_8888) { - runCatching { - val d = ai.loadIcon(pm) ?: ai.applicationInfo.loadIcon(pm) - d.toBitmap(width = 128, height = 128, config = Bitmap.Config.ARGB_8888) - }.getOrNull() + decodeSemaphore.withPermit { + val pkg = ri.activityInfo.packageName + val (stamp, firstInstall) = meta[pkg] ?: (0L to 0L) + val ai = ri.activityInfo + + // Leanback banner (16:9, fixed height 180px is plenty for 1080p/4K TV grids) + val bannerName = "${pkg}_b_$stamp.webp" + val banner = cachedBitmap(cacheDir, bannerName, Bitmap.Config.RGB_565) { + runCatching { + val d = ai.loadBanner(pm) ?: ai.applicationInfo.loadBanner(pm) + d?.let { + val w = 320 + val h = 180 + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.RGB_565) + val canvas = android.graphics.Canvas(bmp) + it.setBounds(0, 0, w, h) + it.draw(canvas) + bmp + } + }.getOrNull() + } + // Only keep cache files that decoded/produced a bitmap: a stale corrupt + // file must be prunable, not protected by a name that never loads. + if (banner != null) validCacheNames.add(bannerName) + + // App icon (only decoded/saved if there's no banner, or as square fallback) + val iconName = "${pkg}_i_$stamp.webp" + val icon = cachedBitmap(cacheDir, iconName, Bitmap.Config.ARGB_8888) { + runCatching { + val d = ai.loadIcon(pm) ?: ai.applicationInfo.loadIcon(pm) + d.toBitmap(width = 128, height = 128, config = Bitmap.Config.ARGB_8888) + }.getOrNull() + } + if (icon != null) validCacheNames.add(iconName) + // Upload textures ahead of first draw so the GPU never stalls mid-frame + banner?.prepareToDraw() + icon?.prepareToDraw() + + AppEntry( + pkg = pkg, + label = runCatching { ri.loadLabel(pm)?.toString() }.getOrNull() ?: pkg, + banner = banner, + icon = icon, + tile = tileColor(pkg), + stamp = stamp, + firstInstall = firstInstall, + ) } - if (icon != null) validCacheNames.add(iconName) - // Upload textures ahead of first draw so the GPU never stalls mid-frame - banner?.prepareToDraw() - icon?.prepareToDraw() - - AppEntry( - pkg = pkg, - label = runCatching { ri.loadLabel(pm)?.toString() }.getOrNull() ?: pkg, - banner = banner, - icon = icon, - tile = tileColor(pkg), - stamp = stamp, - firstInstall = firstInstall, - ) } }.awaitAll() diff --git a/app/src/main/java/com/gothwad/launcher/data/ProcessHeartbeat.kt b/app/src/main/java/com/gothwad/launcher/data/ProcessHeartbeat.kt index 987382f..38b2db7 100644 --- a/app/src/main/java/com/gothwad/launcher/data/ProcessHeartbeat.kt +++ b/app/src/main/java/com/gothwad/launcher/data/ProcessHeartbeat.kt @@ -7,6 +7,7 @@ import android.os.Looper import android.os.SystemClock import android.util.Log import java.io.File +import java.util.concurrent.Executors /** * Cheap, dependency-free liveness heartbeat shared between the main process and the @@ -61,6 +62,38 @@ object ProcessHeartbeat { } }.getOrDefault(context.filesDir) + /** + * Writes [file] atomically: write to a sibling temp file, then `rename` over it. + * + * A plain `writeText` truncates the file first, so a reader that arrives between the + * truncate and the write (the `:watchdog` polls every 15 s) sees an empty/partial file, + * parses `0`, and `isMainProcessStale()` then says "the launcher is dead" - which yanks + * the TV back to HOME mid-movie. `rename(2)` on the same filesystem is atomic, so + * readers only ever see the old or the new content, never a torn one. + */ + internal fun writeAtomic(file: File, content: String) { + val tmp = File(file.parentFile, file.name + ".tmp") + tmp.writeText(content) + if (!tmp.renameTo(file)) { + // Rename failed (should not happen on same-dir internal storage): fall back to + // a direct write rather than losing the update entirely. + file.writeText(content) + runCatching { tmp.delete() } + } + } + + /** + * Single daemon thread that owns every shared-file write. + * + * [touch] is called from the main looper (the beat runnable and the status-bar clock + * loop), and project rule #3 is "no blocking work on the main thread" - not even a + * "few bytes" of file I/O, which is still a syscall pair that can jank on a loaded + * eMMC. The single thread also serialises read-modify-write cycles against themselves. + */ + private val ioExecutor = Executors.newSingleThreadExecutor { r -> + Thread(r, "gothwad-heartbeat").apply { isDaemon = true } + } + private var handler: Handler? = null private val beatRunnable = object : Runnable { @@ -93,19 +126,26 @@ object ProcessHeartbeat { } /** - * Writes the current timestamp. Never throws, never blocks meaningfully: the file - * is a few bytes on internal storage. + * Writes the current timestamp. Never throws and never runs on the caller's thread: + * the write is queued on [ioExecutor] (a few bytes on internal storage). */ fun touch(context: Context, foreground: Boolean) { runCatching { - val now = SystemClock.elapsedRealtime() - val file = File(sharedDir(context), FILE_NAME) - val previous = readPair(file) - // A foreground beat must never wipe the process beat (keep the last known one, - // and publish `now` if we never had one). - val processTs = if (foreground) previous.first.takeIf { it > 0L } ?: now else now - val fgTs = if (foreground) now else previous.second - file.writeText("$processTs\n$fgTs") + // Use the application context when available so a queued task never pins an + // Activity. (Never called from attachBaseContext, so this is safe here.) + val app = context.applicationContext ?: context + ioExecutor.execute { + runCatching { + val now = SystemClock.elapsedRealtime() + val file = File(sharedDir(app), FILE_NAME) + val previous = readPair(file) + // A foreground beat must never wipe the process beat (keep the last known one, + // and publish `now` if we never had one). + val processTs = if (foreground) previous.first.takeIf { it > 0L } ?: now else now + val fgTs = if (foreground) now else previous.second + writeAtomic(file, "$processTs\n$fgTs") + } + } } } @@ -124,7 +164,11 @@ object ProcessHeartbeat { */ fun setRelaunchAllowed(context: Context, allowed: Boolean) { runCatching { - File(sharedDir(context), POLICY_FILE_NAME).writeText(if (allowed) "1" else "0") + val app = context.applicationContext ?: context + val content = if (allowed) "1" else "0" + ioExecutor.execute { + runCatching { writeAtomic(File(sharedDir(app), POLICY_FILE_NAME), content) } + } } } @@ -194,7 +238,9 @@ object SelfHealGuard { private fun write(context: Context, count: Int, windowStart: Long, safeMode: Boolean) { runCatching { - file(context).writeText("$count\n$windowStart\n$safeMode") + // Atomic for the same reason as the heartbeat: the other process polls this + // file and must never parse a torn half-write as "safe mode" or "budget spent". + ProcessHeartbeat.writeAtomic(file(context), "$count\n$windowStart\n$safeMode") } } diff --git a/app/src/main/java/com/gothwad/launcher/service/BootShieldService.kt b/app/src/main/java/com/gothwad/launcher/service/BootShieldService.kt index d92d7bc..bd103bd 100644 --- a/app/src/main/java/com/gothwad/launcher/service/BootShieldService.kt +++ b/app/src/main/java/com/gothwad/launcher/service/BootShieldService.kt @@ -79,21 +79,29 @@ class BootShieldService : Service() { delay(700L) val topPackage = getForegroundPackage(usm, hasUsageAccess) - if (topPackage != null && - LauncherAccessibilityService.isStockTvLauncher(context, topPackage) && - topPackage != packageName - ) { + val stockInFront = topPackage != null && + topPackage != packageName && + LauncherAccessibilityService.isStockTvLauncher(context, topPackage) + if (stockInFront) { Log.w(TAG, "Stock launcher / boot ad tried to take the screen: $topPackage - suppressing") BackgroundMediaTracker.silenceAudio(context) LauncherAccessibilityService.launchHomeWithRetry(context, maxAttempts = 3) } // If an ad starts playing audio during the boot window, silence it - but only - // every couple of seconds instead of on every tick. + // every couple of seconds instead of on every tick, and NEVER while a known + // user app is in front: someone who opened Netflix or their music player + // within the window is using that app on purpose, and killing its audio every + // 2 s is exactly the issue-#34 class of destructive behaviour. Boot ads play + // under the stock launcher, under our own home screen, or with no identifiable + // foreground at all - those are the cases worth silencing. val now = System.currentTimeMillis() if (now - lastAudioCheck > 2_000L) { lastAudioCheck = now - if (audioManager?.isMusicActive == true) { + val userAppInFront = topPackage != null && + topPackage != packageName && + !LauncherAccessibilityService.isStockTvLauncher(context, topPackage) + if (!userAppInFront && audioManager?.isMusicActive == true) { BackgroundMediaTracker.silenceAudio(context) } } diff --git a/app/src/main/java/com/gothwad/launcher/service/LauncherAccessibilityService.kt b/app/src/main/java/com/gothwad/launcher/service/LauncherAccessibilityService.kt index 696aaa8..630f23b 100644 --- a/app/src/main/java/com/gothwad/launcher/service/LauncherAccessibilityService.kt +++ b/app/src/main/java/com/gothwad/launcher/service/LauncherAccessibilityService.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Accessibility Service to handle Home key capture, OEM launcher overrides, @@ -131,8 +132,11 @@ class LauncherAccessibilityService : AccessibilityService() { super.onCreate() val store = ConfigStore(applicationContext) serviceScope.launch { + // PackageManager IPC - IO dispatcher, never the CPU pool (project rule #3). val installedPackages = runCatching { - packageManager.getInstalledPackages(0).map { it.packageName }.toSet() + withContext(Dispatchers.IO) { + packageManager.getInstalledPackages(0).map { it.packageName }.toSet() + } }.getOrDefault(emptySet()) ButtonMappingManager.seedDefaultMappings(store, installedPackages) diff --git a/app/src/main/java/com/gothwad/launcher/ui/DensityAdapter.kt b/app/src/main/java/com/gothwad/launcher/ui/DensityAdapter.kt index 4fda4fd..5c36732 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/DensityAdapter.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/DensityAdapter.kt @@ -128,18 +128,23 @@ object DensityAdapter { fun setUiScale(context: Context, multiplier: Float): Boolean { val clamped = multiplier.coerceIn(MIN_UI_SCALE, MAX_UI_SCALE) val changed = kotlin.math.abs(clamped - userUiScale) > 0.001f + // Persist only on a real change: this is called on every config emission (any + // settings toggle), and re-writing the same float each time is pointless flash wear. + if (!changed) return false userUiScale = clamped prefs(context)?.edit()?.putFloat(KEY_UI_SCALE, clamped)?.apply() Log.i(TAG, "UI scale set to ${clamped}x") - return changed + return true } /** Persists + applies the "respect system font size" policy. */ fun setRespectSystemFontScale(context: Context, enabled: Boolean): Boolean { val changed = respectSystemFontScale != enabled + // Same "only write real changes" rule as [setUiScale] - see the caller note there. + if (!changed) return false respectSystemFontScale = enabled prefs(context)?.edit()?.putBoolean(KEY_RESPECT_FONT_SCALE, enabled)?.apply() - return changed + return true } /** System font scale, clamped so a broken ROM value cannot render the UI unusable. */ diff --git a/app/src/test/java/com/gothwad/launcher/data/ProcessHeartbeatAtomicWriteTest.kt b/app/src/test/java/com/gothwad/launcher/data/ProcessHeartbeatAtomicWriteTest.kt new file mode 100644 index 0000000..565c9ae --- /dev/null +++ b/app/src/test/java/com/gothwad/launcher/data/ProcessHeartbeatAtomicWriteTest.kt @@ -0,0 +1,48 @@ +package com.gothwad.launcher.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Locks in the atomic-replace write used by every cross-process shared file + * (`gothwad_heartbeat`, `gothwad_relaunch_policy`, `gothwad_selfheal`). + * + * The old `file.writeText(...)` truncated first, so the `:watchdog` polling mid-write could + * read an empty file and conclude "the launcher is dead" - yanking the TV back to HOME. + * A reader must only ever see the old or the new content, never a torn/empty one, and the + * writer must not litter temp files. + */ +class ProcessHeartbeatAtomicWriteTest { + + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun writeAtomic_replacesExistingContent() { + val file = File(tmp.root, "gothwad_heartbeat") + ProcessHeartbeat.writeAtomic(file, "100\n200") + assertEquals("100\n200", file.readText()) + + ProcessHeartbeat.writeAtomic(file, "300\n400") + assertEquals("300\n400", file.readText()) + } + + @Test + fun writeAtomic_createsMissingFile() { + val file = File(tmp.root, "gothwad_selfheal") + ProcessHeartbeat.writeAtomic(file, "0\n0\nfalse") + assertEquals("0\n0\nfalse", file.readText()) + } + + @Test + fun writeAtomic_leavesNoTempFileBehind() { + val file = File(tmp.root, "gothwad_relaunch_policy") + ProcessHeartbeat.writeAtomic(file, "1") + assertEquals("1", file.readText()) + assertFalse(File(tmp.root, "gothwad_relaunch_policy.tmp").exists()) + } +} diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index f5521a8..8f0b641 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -24,7 +24,7 @@ something measurable went wrong before. ``` Application.onCreate ├─ DensityAdapter.init (sync, cheap: one screen-metrics read + a SharedPreferences read) - ├─ ProcessHeartbeat.start (writes a few bytes, then every 5 s) + ├─ ProcessHeartbeat.start (queues a tiny write on the heartbeat thread, then every 5 s) ├─ crash handler install (no IO) └─ LauncherWatchdogService.start (startService, returns immediately) @@ -62,6 +62,11 @@ scrolling is not smooth yet". If you add a hot path, add it to the profile. * `validCacheNames` is a `Collections.synchronizedSet`: it is filled from concurrent `async(Dispatchers.IO)` blocks and then used to prune the cache directory. A plain `HashSet` there could corrupt and delete artwork that is still live. +* Whole scans are serialised with a `Mutex`: `MainActivity` and `TvLauncherFragment` both scan + on a cold start, and two concurrent full passes doubled the PM + bitmap work while racing the + prune step. The second caller now waits and hits the memory-cache fast path. +* Per-app decode/rasterise work is capped at **6 concurrent ops** (`Semaphore`); unbounded + `async(IO)` over every installed package used to inflate hundreds of drawables at once. Custom wallpapers are decoded **down-sampled to the physical screen size** and only when the source file actually changed (`appliedWallpaperKey`). Previously every config emission re-decoded diff --git a/docs/TESTING.md b/docs/TESTING.md index c61de13..407b243 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -4,8 +4,9 @@ ``` app/src/test/java/com/gothwad/launcher/data/ -├── ConfigSerializationTest.kt 5 tests -└── LockSecurityTest.kt 10 tests +├── ConfigSerializationTest.kt 5 tests +├── LockSecurityTest.kt 10 tests +└── ProcessHeartbeatAtomicWriteTest.kt 3 tests ``` JVM only (`testDebugUnitTest`), JUnit 4.13.2 — no Android runtime, no Robolectric, no Espresso. @@ -52,6 +53,16 @@ seconds with no device. Keep any new crypto/parsing logic in `data/` free of And * reveal-code hash/verify round-trip, case-insensitive (TV remotes), and empty-code rejection; * throttle: free attempts, exponential backoff, 5-minute cap, reset on success. +**ProcessHeartbeatAtomicWriteTest** + +* `writeAtomic` replaces existing content and creates a missing file; +* no `.tmp` litter is left behind (the temp file is renamed into place, not copied). + +The shared cross-process files (`gothwad_heartbeat`, `gothwad_relaunch_policy`, +`gothwad_selfheal`) must be written through `ProcessHeartbeat.writeAtomic` only: a plain +`writeText` truncates first, and the `:watchdog` reading in that window would parse an empty +file as "the launcher is dead" and yank the screen back to HOME. + ## What to add when you change something | You touched… | Add a test for… | From aeb8a2b29ed0747dd0ad19d519be6cd4cad07ae5 Mon Sep 17 00:00:00 2001 From: GothwadTech <295316515+GothwadTech@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:28:29 +0000 Subject: [PATCH 2/9] Settings: fix invisible D-pad focus + colour bugs; visual wallpaper picker with auto-change Settings UI fixes (colour + remote navigation): - 27 of 33 buttons hard-coded white text while the focused background is solid white, so the label vanished exactly when the control got focus ('remote navigation pata hi nahi chalta'). All buttons now use the color_settings_btn_text state list. - Chevron icons were replaced with static-colour drawables that killed the state-aware tint; back/close icons had the same fixed-white bug. Both now flip dark on the white focus highlight. - Selected state was identical to focused (both solid white). Selected now draws as the accent-cyan chip with a dark label. - SettingsDisplayDelegate force-set text colours in code, overriding the focus state lists; it now drives selection through isSelected. - Entering a settings sub-page left D-pad focus on the hidden row (remote looked dead); every page change now focuses the first control. Wallpaper picker (user-visible feature): - The five name-only buttons (labels that no longer matched their gradients) are replaced by visual tiles rendering the actual gradients, a live preview box, and a check badge on the committed choice. The custom photo is a tile too. - Auto-change wallpaper: Off / 15 min / 1 hour / 6 hours / Daily, rotating the presets via the existing clock loop (WallpaperRotator - no alarms/services; a custom photo is never auto-replaced; long absence advances one step only). Tests: WallpaperRotatorTest (6) covers the rotation rule. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- CHANGELOG.md | 22 +- .../java/com/gothwad/launcher/data/Config.kt | 7 + .../gothwad/launcher/data/WallpaperRotator.kt | 55 +++++ .../launcher/ui/MainStatusBarController.kt | 6 + .../ui/dialogs/SettingsBottomSheetFragment.kt | 28 +++ .../ui/dialogs/SettingsDisplayDelegate.kt | 20 +- .../launcher/ui/dialogs/SettingsMenuHelper.kt | 47 ++-- .../ui/dialogs/SettingsWallpaperDelegate.kt | 158 ++++++++++--- .../ui/dialogs/WallpaperTileAdapter.kt | 103 +++++++++ .../main/res/color/color_settings_icon.xml | 13 ++ .../main/res/drawable/bg_check_badge_cyan.xml | 5 + app/src/main/res/drawable/bg_settings_row.xml | 11 +- .../main/res/layout/item_wallpaper_tile.xml | 53 +++++ app/src/main/res/layout/sheet_settings.xml | 211 +++++++++++------- app/src/main/res/values/strings.xml | 11 + .../launcher/data/WallpaperRotatorTest.kt | 71 ++++++ docs/TESTING.md | 12 +- 17 files changed, 683 insertions(+), 150 deletions(-) create mode 100644 app/src/main/java/com/gothwad/launcher/data/WallpaperRotator.kt create mode 100644 app/src/main/java/com/gothwad/launcher/ui/dialogs/WallpaperTileAdapter.kt create mode 100644 app/src/main/res/color/color_settings_icon.xml create mode 100644 app/src/main/res/drawable/bg_check_badge_cyan.xml create mode 100644 app/src/main/res/layout/item_wallpaper_tile.xml create mode 100644 app/src/test/java/com/gothwad/launcher/data/WallpaperRotatorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d1569b..65a4730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,17 @@ is cut. - **`PACKAGE_REMOVED` broadcasts no longer do PackageManager lookups** on the main thread for a package that is already gone; corrupt app-art cache files are no longer protected from pruning by a cache-name entry for a bitmap that never loaded. +- **Settings: focus was invisible on most controls ("remote navigation pata hi nahi chalta").** + 27 of 33 buttons hard-coded white text while the focused background is solid white — the + label disappeared exactly when the control received focus. All buttons now use the + `color_settings_btn_text` state list (dark on the white highlight). The chevron icons and the + back/close icons had the same bug (fixed-colour drawables that never flipped on focus — the + chevron state list was even being overwritten with a static-colour drawable), and the + selected state of a settings row used to look identical to the focused state. Selected now + draws as the accent-cyan chip, focused stays the white highlight. +- **Settings: entering a sub-page left D-pad focus on the hidden row**, so the remote looked + dead until a direction key re-triggered focus search. Every page change now moves focus to + its first control. ### Fixed — security & privacy @@ -138,12 +149,19 @@ is cut. ### Added +- **Visual wallpaper picker:** the five name-only buttons (whose labels had drifted away from the + gradients they rendered) are now tiles that show the *actual* gradient, with a live preview box + above and a check badge on the committed choice — pick "this picture", not "a name". +- **Auto-change wallpapers:** Off / 15 min / 1 hour / 6 hours / Daily. Rotates through the preset + gradients via the existing 1 Hz clock loop (no alarms, no services); a custom photo is never + auto-replaced, and a long process absence advances exactly one step. - **CI workflow** (`ci.yml`): every pull request and push to `main` builds debug **and release** (minified), runs unit tests and lint, and publishes the debug APK plus reports as artifacts. - Composite actions `resolve-version` (monotonic version code from the version name) and `prepare-signing` (keystore from secrets, alias auto-detection, always-cleaned temp file). -- Unit tests: `ConfigSerializationTest` (5), `LockSecurityTest` (10) and - `ProcessHeartbeatAtomicWriteTest` (3, locks in the atomic shared-file write). +- Unit tests: `ConfigSerializationTest` (5), `LockSecurityTest` (10), + `ProcessHeartbeatAtomicWriteTest` (3, locks in the atomic shared-file write) and + `WallpaperRotatorTest` (6, auto-change rotation logic). - Developer documentation under `docs/`: architecture, config reference, security, performance, CI & release, testing, troubleshooting, i18n, issue log, roadmap. - Issue and pull-request templates, `CONTRIBUTING.md`, and this changelog. diff --git a/app/src/main/java/com/gothwad/launcher/data/Config.kt b/app/src/main/java/com/gothwad/launcher/data/Config.kt index 73e340b..604bb77 100644 --- a/app/src/main/java/com/gothwad/launcher/data/Config.kt +++ b/app/src/main/java/com/gothwad/launcher/data/Config.kt @@ -73,6 +73,13 @@ data class LauncherConfig( /** Index into `WALLPAPERS` (ui/Theme.kt); see `DEFAULT_WALLPAPER_INDEX`. */ val wallpaper: Int = 1, val useCustomWallpaper: Boolean = false, + /** + * Automatic wallpaper rotation interval in minutes. `0` = off. The presets + * ([WALLPAPERS]) rotate in order; a custom wallpaper is never auto-replaced. + */ + val wallpaperAutoMinutes: Int = 0, + /** `System.currentTimeMillis()` of the last auto-rotation. */ + val wallpaperAutoLastMs: Long = 0L, val accent: Int = 0, val h24: Boolean = false, val showHidden: Boolean = false, diff --git a/app/src/main/java/com/gothwad/launcher/data/WallpaperRotator.kt b/app/src/main/java/com/gothwad/launcher/data/WallpaperRotator.kt new file mode 100644 index 0000000..dda8fd3 --- /dev/null +++ b/app/src/main/java/com/gothwad/launcher/data/WallpaperRotator.kt @@ -0,0 +1,55 @@ +package com.gothwad.launcher.data + +import android.util.Log +import com.gothwad.launcher.ui.WALLPAPERS +import kotlinx.coroutines.flow.first + +/** + * Automatic wallpaper rotation over the built-in [WALLPAPERS] presets. + * + * Runs entirely off the existing 1 Hz status-bar clock loop in the main process - no + * AlarmManager, no extra service, no wakeups: the launcher is a HOME app that is awake + * whenever anyone looks at the TV. When the process was dead for a while the rotation + * advances exactly one step (no "catch-up burst" that flashes through five wallpapers). + * + * A custom wallpaper is never replaced by the rotation; the feature simply idles until the + * user switches back to presets. + */ +object WallpaperRotator { + + private const val TAG = "WallpaperRotator" + + /** Interval choices offered in Settings (minutes; 0 = off). */ + val INTERVAL_MINUTES: List = listOf(0, 15, 60, 360, 1440) + + /** + * Pure decision: should a rotation happen at [nowMs], and what does the config become? + * + * @return the rotated config, or `null` when nothing should change. + */ + fun rotateIfDue(config: LauncherConfig, nowMs: Long): LauncherConfig? { + if (config.wallpaperAutoMinutes <= 0) return null + if (config.useCustomWallpaper) return null + if (WALLPAPERS.isEmpty()) return null + + val periodMs = config.wallpaperAutoMinutes * 60_000L + if (nowMs - config.wallpaperAutoLastMs < periodMs) return null + + val next = (config.wallpaper + 1).mod(WALLPAPERS.size) + return config.copy(wallpaper = next, wallpaperAutoLastMs = nowMs) + } + + /** Applies [rotateIfDue] through the store; safe to call on every clock tick. */ + suspend fun tick(store: ConfigStore) { + val nowMs = System.currentTimeMillis() + // Cheap no-write path first: ConfigStore.update always re-encodes and persists, + // and this runs every minute - 99% of ticks must not touch the DataStore. + val current = runCatching { store.flow.first() }.getOrNull() ?: return + if (rotateIfDue(current, nowMs) == null) return + store.update { cfg -> + val rotated = rotateIfDue(cfg, nowMs) ?: return@update cfg + Log.i(TAG, "Auto-rotation: wallpaper -> index ${rotated.wallpaper}") + rotated + } + } +} diff --git a/app/src/main/java/com/gothwad/launcher/ui/MainStatusBarController.kt b/app/src/main/java/com/gothwad/launcher/ui/MainStatusBarController.kt index 60960ed..b97091f 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/MainStatusBarController.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/MainStatusBarController.kt @@ -17,6 +17,7 @@ import com.gothwad.launcher.data.LauncherConfig import com.gothwad.launcher.data.NetStatus import com.gothwad.launcher.data.ProcessHeartbeat import com.gothwad.launcher.data.UI_SCALES +import com.gothwad.launcher.data.WallpaperRotator import com.gothwad.launcher.data.bluetoothStatusFlow import com.gothwad.launcher.data.networkStatusFlow import com.gothwad.launcher.databinding.ActivityMainBinding @@ -240,6 +241,11 @@ class MainStatusBarController( if (heartbeatTick++ % 5 == 0) { ProcessHeartbeat.touch(activity, foreground = true) } + // Once a minute: rotate the wallpaper when the auto-change interval + // elapsed. Cheap no-op unless it is actually due (see WallpaperRotator). + if (heartbeatTick % 60 == 0) { + runCatching { WallpaperRotator.tick(configStore) } + } val now = Date() val cfg = getCurrentConfig() val timePattern = if (cfg.h24) "HH:mm:ss" else "hh:mm:ss a" diff --git a/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsBottomSheetFragment.kt b/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsBottomSheetFragment.kt index 2208cb2..b2c2456 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsBottomSheetFragment.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsBottomSheetFragment.kt @@ -11,6 +11,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import com.gothwad.launcher.Actions @@ -68,6 +69,7 @@ class SettingsBottomSheetFragment : DialogFragment() { } store.update { it.copy(useCustomWallpaper = true) } config = config.copy(useCustomWallpaper = true) + wallpaperDelegate.syncSelection() updateSubtitles() onWallpaperChanged?.invoke() Actions.toast(context, "Custom wallpaper applied") @@ -186,14 +188,20 @@ class SettingsBottomSheetFragment : DialogFragment() { private fun setupBackdropAndHeader() { binding.scrimBackdrop.setOnClickListener { dismiss() } + // White base glyph + state-list tint: the close/back icons must turn dark when the + // white focus highlight of bg_settings_row lands on them (a fixed white icon + // vanished exactly while focused). + val iconTint = ContextCompat.getColorStateList(requireContext(), R.color.color_settings_icon) binding.btnCloseSettings.setImageDrawable( AppIcons.createDrawable(AppIcons.PATH_CLOSE, Color.WHITE) ) + binding.btnCloseSettings.imageTintList = iconTint binding.btnCloseSettings.setOnClickListener { dismiss() } binding.btnBack.setImageDrawable( AppIcons.createDrawable(AppIcons.PATH_BACK, Color.WHITE) ) + binding.btnBack.imageTintList = iconTint binding.btnBack.setOnClickListener { handleBackPress() } @@ -552,6 +560,25 @@ class SettingsBottomSheetFragment : DialogFragment() { } binding.txtHeroSubtitle.text = heroDesc binding.scrollContainer.smoothScrollTo(0, 0) + + // Put D-pad focus on the page's first control. Before this, entering a sub-page + // left focus on the now-GONE row; on many TV firmwares the remote then appears + // dead until a direction key happens to re-trigger focus search. + binding.scrollContainer.post { focusFirstDescendant(subPage) } + } + + /** Requests focus on the first visible, focusable descendant of [root]. */ + private fun focusFirstDescendant(root: View): Boolean { + if (root is ViewGroup) { + for (i in 0 until root.childCount) { + val child = root.getChildAt(i) + if (child.visibility != View.VISIBLE) continue + if (child.isFocusable && child.isEnabled && child.requestFocus()) return true + if (child is ViewGroup && focusFirstDescendant(child)) return true + } + return false + } + return root.isFocusable && root.isEnabled && root.requestFocus() } private fun navigateToRoot() { @@ -567,6 +594,7 @@ class SettingsBottomSheetFragment : DialogFragment() { binding.txtSettingsTitle.setText(R.string.settings_root_title) binding.txtHeroSubtitle.setText(R.string.settings_root_subtitle) binding.scrollContainer.smoothScrollTo(0, 0) + binding.scrollContainer.post { focusFirstDescendant(binding.layoutRootMenu) } } private fun safeStartActivity(action: String, fallbackAction: String? = null) { diff --git a/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsDisplayDelegate.kt b/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsDisplayDelegate.kt index 411e701..6ef5dec 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsDisplayDelegate.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsDisplayDelegate.kt @@ -1,6 +1,5 @@ package com.gothwad.launcher.ui.dialogs -import android.graphics.Color import androidx.lifecycle.lifecycleScope import com.gothwad.launcher.Actions import com.gothwad.launcher.R @@ -38,14 +37,17 @@ class SettingsDisplayDelegate( binding.btnCorner4 to 4 ) + /** + * Selection is drawn with `isSelected` + the shared state lists + * (`bg_settings_row` cyan chip, `color_settings_btn_text` dark label). + * The old code force-set static text colours (`#4DD0E1` / `#FFFFFF`), which + * overrode the focus-aware state list and made the label invisible on the white + * focus highlight - "focus pata hi nahi chalta". + */ fun highlightSelectedCorner(selectedIdx: Int) { updateCornerPreview(selectedIdx) for ((btn, idx) in cornerButtons) { - if (idx == selectedIdx) { - btn.setTextColor(Color.parseColor("#4DD0E1")) - } else { - btn.setTextColor(Color.parseColor("#FFFFFF")) - } + btn.isSelected = idx == selectedIdx } } @@ -79,11 +81,7 @@ class SettingsDisplayDelegate( ) fun highlightSelectedScale(selectedIdx: Int) { for ((btn, idx) in uiScaleButtons) { - if (idx == selectedIdx) { - btn.setTextColor(Color.parseColor("#4DD0E1")) - } else { - btn.setTextColor(Color.parseColor("#FFFFFF")) - } + btn.isSelected = idx == selectedIdx } } highlightSelectedScale(getConfig().uiScale) diff --git a/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsMenuHelper.kt b/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsMenuHelper.kt index 0c6d4b4..d8c6ecd 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsMenuHelper.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsMenuHelper.kt @@ -1,38 +1,28 @@ package com.gothwad.launcher.ui.dialogs import android.content.Context -import android.graphics.Color import android.net.wifi.WifiManager import android.os.Build import com.gothwad.launcher.R import com.gothwad.launcher.data.LauncherConfig import com.gothwad.launcher.data.UI_SCALES import com.gothwad.launcher.databinding.SheetSettingsBinding -import com.gothwad.launcher.ui.AppIcons import com.gothwad.launcher.ui.WALLPAPERS object SettingsMenuHelper { - fun setupMenuIcons(binding: SheetSettingsBinding) { - val chevronColor = Color.parseColor("#94A3B8") - val chevronDrawable = AppIcons.createDrawable(AppIcons.PATH_CHEVRON_RIGHT, chevronColor) - - // 4 Main Categories - binding.chevronCatAppearance.setImageDrawable(chevronDrawable) - binding.chevronCatApps.setImageDrawable(chevronDrawable) - binding.chevronCatRemote.setImageDrawable(chevronDrawable) - binding.chevronCatDevice.setImageDrawable(chevronDrawable) - - // Sub-page items - binding.chevronWallpaper.setImageDrawable(chevronDrawable) - binding.chevronDisplay.setImageDrawable(chevronDrawable) - binding.chevronStatusbar.setImageDrawable(chevronDrawable) - binding.chevronApps.setImageDrawable(chevronDrawable) - binding.chevronSecurity.setImageDrawable(chevronDrawable) - binding.chevronNetwork.setImageDrawable(chevronDrawable) - binding.chevronDevicePrefs.setImageDrawable(chevronDrawable) - binding.chevronPermissions.setImageDrawable(chevronDrawable) - binding.chevronCatAbout.setImageDrawable(chevronDrawable) + /** + * Chevrons are intentionally NOT replaced here any more. + * + * This used to overwrite every `ic_settings_chevron` ImageView with a static-colour + * `AppIcons` drawable (`#94A3B8`), which killed the state-list tint + * (`@color/color_settings_chevron`) the XML vector carries. Combined with the white + * focused row background, the chevron stayed pale grey exactly when the row was + * focused. The XML `android:src` + `android:tint` (with `duplicateParentState`) + * already tracks focus/pressed/selected correctly. + */ + fun setupMenuIcons(@Suppress("UNUSED_PARAMETER") binding: SheetSettingsBinding) { + // no-op on purpose - see kdoc } fun updateSubtitles(binding: SheetSettingsBinding, config: LauncherConfig, context: Context) { @@ -54,7 +44,18 @@ object SettingsMenuHelper { else -> R.string.scrim_full } ) - binding.txtWallpaperSubtitle.text = "$wpName • $scrimName" + val autoLabel = when (config.wallpaperAutoMinutes) { + 15 -> context.getString(R.string.wallpaper_auto_15min) + 60 -> context.getString(R.string.wallpaper_auto_hour) + 360 -> context.getString(R.string.wallpaper_auto_6h) + 1440 -> context.getString(R.string.wallpaper_auto_daily) + else -> null + } + binding.txtWallpaperSubtitle.text = if (autoLabel != null) { + "$wpName • $scrimName • " + context.getString(R.string.wallpaper_auto_badge, autoLabel) + } else { + "$wpName • $scrimName" + } val uiScaleName = UI_SCALES.getOrElse(config.uiScale.coerceIn(0, UI_SCALES.size - 1)) { 1.0f } binding.txtDisplaySubtitle.text = "6-Column Grid • UI ${uiScaleName}x" diff --git a/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsWallpaperDelegate.kt b/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsWallpaperDelegate.kt index d0dba34..686b9e7 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsWallpaperDelegate.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsWallpaperDelegate.kt @@ -1,14 +1,26 @@ package com.gothwad.launcher.ui.dialogs +import android.graphics.drawable.GradientDrawable import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager import com.gothwad.launcher.R import com.gothwad.launcher.data.ConfigStore import com.gothwad.launcher.data.LauncherConfig +import com.gothwad.launcher.data.WallpaperRotator import com.gothwad.launcher.databinding.SheetSettingsBinding import com.gothwad.launcher.ui.DEFAULT_WALLPAPER_INDEX import com.gothwad.launcher.ui.WALLPAPERS import kotlinx.coroutines.launch +/** + * Wallpaper & Themes page: visual preset tiles with a live preview box, scrim modes and + * the auto-rotation interval. + * + * Picking a wallpaper used to be five text buttons ("Deep Space", "Cyberpunk Neon", ...) + * whose names had nothing to do with the gradients they rendered - the user could not see + * what they were choosing. The tiles now *are* the gradients, and the preview box on top + * shows the focused one full-size before committing with OK. + */ class SettingsWallpaperDelegate( private val fragment: SettingsBottomSheetFragment, private val binding: SheetSettingsBinding, @@ -17,66 +29,142 @@ class SettingsWallpaperDelegate( private val updateConfig: (LauncherConfig) -> Unit, private val onWallpaperChanged: (() -> Unit)?, private val openPhotoPicker: () -> Unit, - private val updateSubtitles: () -> Unit + private val updateSubtitles: () -> Unit, ) { - fun bind() { - val wpButtons = listOf( - binding.btnWp0 to 0, - binding.btnWp1 to 1, - binding.btnWp2 to 2, - binding.btnWp3 to 3, - binding.btnWp4 to 4 - ) + private var tileAdapter: WallpaperTileAdapter? = null - // Label the buttons from the preset list itself. The XML used to carry its own names - // ("Deep Space", "Cyberpunk Neon", "Obsidian Minimal") which had drifted away from the - // gradients actually rendered by Theme.kt, so four of the five buttons lied. + fun bind() { val context = fragment.requireContext() - for ((btn, wpIdx) in wpButtons) { - val preset = WALLPAPERS.getOrNull(wpIdx) - if (preset != null) { - btn.text = if (wpIdx == DEFAULT_WALLPAPER_INDEX) { - context.getString(R.string.wallpaper_option_default, preset.displayLabel()) + + // ----- visual preset tiles + live preview ----- + tileAdapter = WallpaperTileAdapter( + onFocused = { item -> showPreview(item.presetIndex) }, + onSelect = { item -> + if (item.isCustom) { + openPhotoPicker() } else { - preset.displayLabel() + selectPreset(item.presetIndex) } - } - btn.setOnClickListener { - fragment.viewLifecycleOwner.lifecycleScope.launch { - store.update { - it.copy(wallpaper = wpIdx, useCustomWallpaper = false) - } - val newConfig = getConfig().copy(wallpaper = wpIdx, useCustomWallpaper = false) - updateConfig(newConfig) - updateSubtitles() - onWallpaperChanged?.invoke() - } - } - } + }, + ) + binding.recyclerWallpapers.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + binding.recyclerWallpapers.adapter = tileAdapter + binding.recyclerWallpapers.itemAnimator = null + + syncSelection() binding.btnPickCustomWallpaper.setOnClickListener { openPhotoPicker() } + // ----- scrim modes ----- val scrimButtons = listOf( binding.btnScrim0 to 0, binding.btnScrim1 to 1, binding.btnScrim2 to 2, binding.btnScrim3 to 3, - binding.btnScrim4 to 4 + binding.btnScrim4 to 4, ) - + fun syncScrim() { + val current = getConfig().scrimMode + for ((btn, mode) in scrimButtons) btn.isSelected = mode == current + } + syncScrim() for ((btn, scrimMode) in scrimButtons) { btn.setOnClickListener { fragment.viewLifecycleOwner.lifecycleScope.launch { store.update { it.copy(scrimMode = scrimMode) } - val newConfig = getConfig().copy(scrimMode = scrimMode) - updateConfig(newConfig) + updateConfig(getConfig().copy(scrimMode = scrimMode)) + syncScrim() updateSubtitles() onWallpaperChanged?.invoke() } } } + + // ----- auto-change interval ----- + val autoButtons = listOf( + binding.btnAutoWp0 to 0, + binding.btnAutoWp1 to 1, + binding.btnAutoWp2 to 2, + binding.btnAutoWp3 to 3, + binding.btnAutoWp4 to 4, + ) + fun syncAuto() { + val current = getConfig().wallpaperAutoMinutes + for ((btn, idx) in autoButtons) { + btn.isSelected = WallpaperRotator.INTERVAL_MINUTES[idx] == current + } + } + syncAuto() + for ((btn, idx) in autoButtons) { + btn.setOnClickListener { + val minutes = WallpaperRotator.INTERVAL_MINUTES[idx] + fragment.viewLifecycleOwner.lifecycleScope.launch { + store.update { + it.copy( + wallpaperAutoMinutes = minutes, + // Start the window fresh so switching "15 min" does not instantly rotate. + wallpaperAutoLastMs = System.currentTimeMillis(), + ) + } + updateConfig(getConfig().copy(wallpaperAutoMinutes = minutes)) + syncAuto() + updateSubtitles() + } + } + } + } + + /** Re-syncs tiles + preview to the committed config (e.g. after the photo picker). */ + fun syncSelection() { + val config = getConfig() + val presetIdx = if (config.useCustomWallpaper) { + WallpaperTileAdapter.TileItem.CUSTOM + } else { + config.wallpaper.coerceIn(0, WALLPAPERS.size - 1) + } + tileAdapter?.selectPreset(presetIdx) + showPreview(presetIdx) + } + + private fun selectPreset(presetIdx: Int) { + fragment.viewLifecycleOwner.lifecycleScope.launch { + store.update { + it.copy(wallpaper = presetIdx, useCustomWallpaper = false) + } + updateConfig(getConfig().copy(wallpaper = presetIdx, useCustomWallpaper = false)) + syncSelection() + updateSubtitles() + onWallpaperChanged?.invoke() + } + } + + /** + * Fills the preview box with the gradient for [presetIdx] ([WallpaperTileAdapter.TileItem.CUSTOM] + * shows the "custom photo" placeholder until one is picked). + */ + private fun showPreview(presetIdx: Int) { + val context = fragment.requireContext() + if (presetIdx == WallpaperTileAdapter.TileItem.CUSTOM) { + binding.imgWallpaperPreview.setImageDrawable( + GradientDrawable( + GradientDrawable.Orientation.TL_BR, + intArrayOf(0xFF16161A.toInt(), 0xFF2F3437.toInt()), + ) + ) + binding.txtWallpaperPreviewName.text = context.getString(R.string.wallpaper_tile_custom) + return + } + val preset = WALLPAPERS.getOrElse(presetIdx) { WALLPAPERS[DEFAULT_WALLPAPER_INDEX] } + binding.imgWallpaperPreview.setImageDrawable( + GradientDrawable( + GradientDrawable.Orientation.TL_BR, + preset.colors.toIntArray(), + ) + ) + binding.txtWallpaperPreviewName.text = preset.displayLabel() } } diff --git a/app/src/main/java/com/gothwad/launcher/ui/dialogs/WallpaperTileAdapter.kt b/app/src/main/java/com/gothwad/launcher/ui/dialogs/WallpaperTileAdapter.kt new file mode 100644 index 0000000..5d43b98 --- /dev/null +++ b/app/src/main/java/com/gothwad/launcher/ui/dialogs/WallpaperTileAdapter.kt @@ -0,0 +1,103 @@ +package com.gothwad.launcher.ui.dialogs + +import android.graphics.drawable.GradientDrawable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.gothwad.launcher.R +import com.gothwad.launcher.databinding.ItemWallpaperTileBinding +import com.gothwad.launcher.ui.DEFAULT_WALLPAPER_INDEX +import com.gothwad.launcher.ui.WALLPAPERS + +/** + * Visual wallpaper picker tiles: each tile renders the *actual* gradient it selects, so the + * choice is "this picture" instead of "a name like Moon / Day / Reddish". A custom-photo + * tile sits after the presets. + */ +class WallpaperTileAdapter( + private val onFocused: (item: TileItem) -> Unit, + private val onSelect: (item: TileItem) -> Unit, +) : RecyclerView.Adapter() { + + /** One picker entry: a preset ([presetIndex] >= 0) or the custom photo ([CUSTOM]). */ + data class TileItem( + val presetIndex: Int, + val label: String, + val colors: List, + ) { + val isCustom: Boolean get() = presetIndex == CUSTOM + + companion object { + const val CUSTOM = -1 + } + } + + private val items: List = buildList { + WALLPAPERS.forEachIndexed { i, p -> add(TileItem(i, p.displayLabel(), p.colors)) } + add(TileItem(TileItem.CUSTOM, "", listOf(0xFF16161A.toInt(), 0xFF2F3437.toInt()))) + } + + /** Currently committed selection ([TileItem.CUSTOM] or a preset index). */ + var selectedPresetIndex: Int = DEFAULT_WALLPAPER_INDEX + + fun selectPreset(index: Int) { + val old = selectedPresetIndex + selectedPresetIndex = index + items.indexOfFirst { it.presetIndex == old }.takeIf { it >= 0 }?.let { notifyItemChanged(it) } + items.indexOfFirst { it.presetIndex == index }.takeIf { it >= 0 }?.let { notifyItemChanged(it) } + } + + inner class TileHolder( + private val binding: ItemWallpaperTileBinding, + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: TileItem) { + val ctx = binding.root.context + binding.tileName.text = if (item.isCustom) { + ctx.getString(R.string.wallpaper_tile_custom) + } else { + item.label + } + binding.tileGradient.background = gradientFor(item, binding.root.resources.displayMetrics.density) + binding.tileCheck.visibility = + if (item.presetIndex == selectedPresetIndex) View.VISIBLE else View.GONE + + binding.root.setOnClickListener { onSelect(item) } + binding.root.setOnFocusChangeListener { v, hasFocus -> + applyFocusStroke(v, hasFocus) + if (hasFocus) onFocused(item) + } + } + + private fun applyFocusStroke(view: View, focused: Boolean) { + val gd = binding.tileGradient.background as? GradientDrawable ?: return + val density = view.resources.displayMetrics.density + gd.cornerRadius = 8 * density + if (focused) { + gd.setStroke((2.5f * density).toInt(), 0xFFFFFFFF.toInt()) + } else { + gd.setStroke(0, 0) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TileHolder { + val binding = ItemWallpaperTileBinding.inflate( + LayoutInflater.from(parent.context), parent, false, + ) + return TileHolder(binding) + } + + override fun getItemCount(): Int = items.size + + override fun onBindViewHolder(holder: TileHolder, position: Int) { + holder.bind(items[position]) + } + + private fun gradientFor(item: TileItem, density: Float): GradientDrawable = + GradientDrawable( + GradientDrawable.Orientation.TL_BR, + item.colors.toIntArray(), + ).apply { cornerRadius = 8 * density } +} diff --git a/app/src/main/res/color/color_settings_icon.xml b/app/src/main/res/color/color_settings_icon.xml new file mode 100644 index 0000000..271594d --- /dev/null +++ b/app/src/main/res/color/color_settings_icon.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/bg_check_badge_cyan.xml b/app/src/main/res/drawable/bg_check_badge_cyan.xml new file mode 100644 index 0000000..31a0d22 --- /dev/null +++ b/app/src/main/res/drawable/bg_check_badge_cyan.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/bg_settings_row.xml b/app/src/main/res/drawable/bg_settings_row.xml index f1709d4..7cce71d 100644 --- a/app/src/main/res/drawable/bg_settings_row.xml +++ b/app/src/main/res/drawable/bg_settings_row.xml @@ -14,10 +14,16 @@ - + - + @@ -29,4 +35,3 @@ - diff --git a/app/src/main/res/layout/item_wallpaper_tile.xml b/app/src/main/res/layout/item_wallpaper_tile.xml new file mode 100644 index 0000000..e3f4a49 --- /dev/null +++ b/app/src/main/res/layout/item_wallpaper_tile.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/sheet_settings.xml b/app/src/main/res/layout/sheet_settings.xml index 10177bb..98e3907 100644 --- a/app/src/main/res/layout/sheet_settings.xml +++ b/app/src/main/res/layout/sheet_settings.xml @@ -787,65 +787,50 @@ android:textSize="12sp" android:textStyle="bold" /> -