diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3837aba --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +# Builds and tests every push and pull request. This is the project's real compiler — +# the sources are often edited in environments without an Android SDK, so green here is +# the bar for merging. + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-test: + name: Build, Test & Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + # The custom `debugConfig` signing config is not auto-generated by AGP — + # every workflow that runs assembleDebug must create this keystore first. + - name: Create debug keystore + run: | + mkdir -p ~/.android + if [ ! -f ~/.android/debug.keystore ]; then + keytool -genkeypair -v \ + -keystore ~/.android/debug.keystore \ + -storepass android -keypass android \ + -alias androiddebugkey \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=Android Debug,O=Android,C=US" + fi + + - name: Static resource checks + run: | + python3 tools/ktcheck.py + python3 tools/check_resources.py + + # gradlew is checked in without the exec bit on some checkouts. + # Gradle output is mirrored into check-run annotations (::error lines) because + # raw job logs are not reachable from every dev environment. + - name: Assemble debug + run: | + chmod +x ./gradlew + set -o pipefail + ./gradlew assembleDebug --stacktrace 2>&1 | tee /tmp/build.log || { + { grep -E "^e: " /tmp/build.log | head -9; + grep -n -i -E "error:|failed|what went wrong" /tmp/build.log | head -5; } | \ + sed 's/%/%25/g; s/\r//g' | \ + while IFS= read -r line; do echo "::error::${line}"; done + exit 1 + } + + - name: Unit tests + run: | + set -o pipefail + ./gradlew testDebugUnitTest --stacktrace 2>&1 | tee /tmp/test.log || { + grep -E "^e: |FAILED|tests? (completed|failed)|What went wrong" -A 3 /tmp/test.log | head -60 | \ + while IFS= read -r line; do echo "::error::${line}"; done + grep -rl "failure" app/build/test-results/testDebugUnitTest/*.xml 2>/dev/null | head -5 | \ + while IFS= read -r f; do grep -o 'message="[^"]*"' "$f" | head -3; done | \ + while IFS= read -r line; do echo "::error::${line}"; done + exit 1 + } + + - name: Lint + run: ./gradlew lintDebug --stacktrace + + - name: Assemble release (minified) + run: ./gradlew assembleRelease --stacktrace + + - name: Upload APKs and reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: ci-artifacts + path: | + app/build/outputs/apk/**/*.apk + app/build/reports/** + retention-days: 14 diff --git a/CHANGELOG.md b/CHANGELOG.md index dc10e88..68fc0fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,42 @@ 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. +- **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 @@ -85,6 +121,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 +132,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. @@ -109,11 +149,29 @@ 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. +- **Theme & Style settings page** (the 100-feature customization wave, see + `docs/FEATURE_ROADMAP_100.md`): 10 full themes with visual tiles, 10 accent colours, + wallpaper-palette accent, 5 new wallpapers (10 total), 4 layout modes (banner / compact / + large / icon grid), 3–8 columns, app size 60–160%, tile label show + size, 4 focus effects + (scale / glow / outline / fill) with focus scale, animation speed + reduced-motion + + app-open transitions, performance profiles, clock styles + date formats + time-of-day + greeting, night-light warm overlay, 4 app sort modes, favourites + recents quick row, + long-press app quick menu (launch / info / uninstall / favourite / hide / lock), and + settings backup export/import as JSON through the system file picker. - **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) and `LockSecurityTest` (10). +- Unit tests: `ConfigSerializationTest` (5), `LockSecurityTest` (10), + `ProcessHeartbeatAtomicWriteTest` (3, locks in the atomic shared-file write), + `WallpaperRotatorTest` (6, auto-change rotation logic) and + `CustomizationConfigTest` (7, theme/wallpaper/accent registries + config round-trip). - 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..e473d9a 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() } } @@ -155,8 +158,16 @@ class MainActivity : AppCompatActivity() { SelfHealGuard.markHealthy(this@MainActivity) } + // Night-light overlay follows the config flow for the activity's whole life. + lifecycleScope.launch { + ConfigStore(this@MainActivity).flow.collect { cfg -> + binding.viewNightLight.alpha = + if (cfg.nightLight) cfg.nightLightStrength.coerceIn(10, 80) / 100f * 0.45f + else 0f + } + } + setupA11yWatchdogReceiver() - setupNavigation() statusBarController = MainStatusBarController( activity = this, binding = binding, @@ -214,10 +225,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( @@ -227,13 +234,20 @@ class MainActivity : AppCompatActivity() { config = currentConfig, ) { Actions.launchApp(this, app.pkg) + // App-open transition style (0 none / 1 fade / 2 zoom / 3 slide). + if (!currentConfig.reduceMotion && currentConfig.perfProfile != 0) { + @Suppress("DEPRECATION") + when (currentConfig.openAnimation.coerceIn(0, 3)) { + 1 -> overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) + 2 -> overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) + 3 -> overridePendingTransition( + android.R.anim.slide_in_left, android.R.anim.slide_out_right + ) + } + } } } - 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/AppLaunchTracker.kt b/app/src/main/java/com/gothwad/launcher/data/AppLaunchTracker.kt index 7371327..c5eb7f1 100644 --- a/app/src/main/java/com/gothwad/launcher/data/AppLaunchTracker.kt +++ b/app/src/main/java/com/gothwad/launcher/data/AppLaunchTracker.kt @@ -49,6 +49,11 @@ object AppLaunchTracker { } } + /** Snapshot of recently launched packages, most recent first (for the quick row). */ + fun recent(limit: Int = 8): List = synchronized(lock) { + lruList.take(limit) + } + /** * Executes memory trimming on a background thread. * 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/Config.kt b/app/src/main/java/com/gothwad/launcher/data/Config.kt index 73e340b..1efa1d1 100644 --- a/app/src/main/java/com/gothwad/launcher/data/Config.kt +++ b/app/src/main/java/com/gothwad/launcher/data/Config.kt @@ -73,7 +73,58 @@ 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, + // ----- theme ----- + /** Index into `THEMES` (ui/Theme.kt). */ + val themeIndex: Int = 0, + /** Derive the accent colour from the current wallpaper instead of [accent]. */ + val followWallpaperPalette: Boolean = false, + // ----- header / clock ----- + /** 0 = date + time, 1 = time only, 2 = greeting + time. */ + val clockStyle: Int = 0, + /** 0 = "d MMM • EEE", 1 = "dd/MM/yyyy", 2 = "EEE, d MMM", 3 = hidden. */ + val dateStyle: Int = 0, + val showGreeting: Boolean = true, + // ----- grid & icons ----- + /** 0 = banner grid, 1 = compact grid, 2 = large tiles, 3 = square icon grid. */ + val layoutMode: Int = 0, + /** Columns for grid modes, 3..8. */ + val gridColumns: Int = 6, + /** App card size in percent of the layout mode's base size (60..160). */ + val iconSizePct: Int = 100, + val showTileLabels: Boolean = true, + /** Tile label size in sp (10..16). */ + val labelSizeSp: Int = 12, + /** Focus scale in percent (100..140; 108 = 1.08x). */ + val focusScalePct: Int = 108, + /** Focus effect: 0 = scale, 1 = glow, 2 = outline, 3 = fill. */ + val focusEffect: Int = 0, + /** App-open transition: 0 = none, 1 = fade, 2 = zoom, 3 = slide. */ + val openAnimation: Int = 1, + /** UI animation speed: 0 = slow, 1 = normal, 2 = fast. */ + val animSpeed: Int = 1, + val reduceMotion: Boolean = false, + // ----- rows ----- + val showFavoritesRow: Boolean = false, + /** Package names shown in the favourites row, in user order. */ + val favorites: List = emptyList(), + val showRecentRow: Boolean = false, + // ----- system ----- + /** 0 = name A-Z, 1 = install date, 2 = recently updated, 3 = manual (order map only). */ + val appSort: Int = 0, + /** Warm-screen overlay for night viewing. */ + val nightLight: Boolean = false, + /** Warm overlay strength in percent (10..80). */ + val nightLightStrength: Int = 40, + /** 0 = lite, 1 = balanced, 2 = full (see `docs/PERFORMANCE.md`). */ + val perfProfile: Int = 1, val h24: Boolean = false, val showHidden: Boolean = false, val setupDone: Boolean = false, 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/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/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/main/java/com/gothwad/launcher/ui/MainStatusBarController.kt b/app/src/main/java/com/gothwad/launcher/ui/MainStatusBarController.kt index 60960ed..af3a99e 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 @@ -54,6 +55,8 @@ class MainStatusBarController( private var clockFormatter: SimpleDateFormat? = null private var clockPatternInUse: String? = null private val dateFormatter = SimpleDateFormat("d MMM yyyy • EEE", Locale.ENGLISH) + private val dateShortFormatter = SimpleDateFormat("dd/MM/yyyy", Locale.ENGLISH) + private val dateMediumFormatter = SimpleDateFormat("EEE, d MMM", Locale.ENGLISH) fun setup() { setupStatusBarClicks() @@ -240,6 +243,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" @@ -247,9 +255,34 @@ class MainStatusBarController( clockFormatter = SimpleDateFormat(timePattern, Locale.ENGLISH) clockPatternInUse = timePattern } - val dateFormatted = dateFormatter.format(now) val timeFormatted = clockFormatter!!.format(now) - binding.mainStatusBar.setClockTime("$dateFormatted • $timeFormatted") + val dateFormatted = when (cfg.dateStyle.coerceIn(0, 3)) { + 1 -> dateShortFormatter.format(now) + 2 -> dateMediumFormatter.format(now) + 3 -> "" + else -> dateFormatter.format(now) + } + val greeting = if (cfg.showGreeting || cfg.clockStyle == 2) { + activity.getString( + when (java.util.Calendar.getInstance() + .get(java.util.Calendar.HOUR_OF_DAY)) { + in 5..11 -> com.gothwad.launcher.R.string.greeting_morning + in 12..16 -> com.gothwad.launcher.R.string.greeting_afternoon + in 17..21 -> com.gothwad.launcher.R.string.greeting_evening + else -> com.gothwad.launcher.R.string.greeting_night + } + ) + } else { + "" + } + val clockText = when (cfg.clockStyle.coerceIn(0, 2)) { + 1 -> timeFormatted + 2 -> listOf(greeting, timeFormatted) + .filter { it.isNotEmpty() }.joinToString(" • ") + else -> listOf(dateFormatted, timeFormatted) + .filter { it.isNotEmpty() }.joinToString(" • ") + } + binding.mainStatusBar.setClockTime(clockText) delay(1000) } } diff --git a/app/src/main/java/com/gothwad/launcher/ui/Theme.kt b/app/src/main/java/com/gothwad/launcher/ui/Theme.kt index ff7f306..3b0ab95 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/Theme.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/Theme.kt @@ -7,16 +7,18 @@ val ACCENTS: List = listOf( 0xFFF28B82.toInt(), 0xFFD7AEFB.toInt(), 0xFF78D9EC.toInt(), + 0xFFFF6FD8.toInt(), + 0xFFFFB74D.toInt(), + 0xFFB6FFB0.toInt(), + 0xFFE0E0E0.toInt(), ) /** * A built-in gradient wallpaper. * * This list is the single source of truth for both the rendered gradient *and* its name - - * the Settings wallpaper buttons and every subtitle are labelled from here, so a preset can - * no longer be advertised under a name it does not render (the buttons used to promise - * "Deep Space / Cyberpunk Neon / Obsidian Minimal" while drawing Midnight / Sunset / - * Charcoal). + * the Settings wallpaper tiles and every subtitle are labelled from here, so a preset can + * no longer be advertised under a name it does not render. */ data class WallpaperPreset(val name: String, val emoji: String, val colors: List) { /** Label shown in pickers, e.g. "✨ Aurora". */ @@ -24,16 +26,66 @@ data class WallpaperPreset(val name: String, val emoji: String, val colors: List } val WALLPAPERS: List = listOf( - WallpaperPreset("Midnight", "🌌", listOf(0xFF0F2027.toInt(), 0xFF203A43.toInt(), 0xFF2C5364.toInt())), + WallpaperPreset("Midnight", "\uD83C\uDF0C", listOf(0xFF0F2027.toInt(), 0xFF203A43.toInt(), 0xFF2C5364.toInt())), WallpaperPreset("Aurora", "✨", listOf(0xFF13547A.toInt(), 0xFF2B825B.toInt(), 0xFF80D0C7.toInt())), - WallpaperPreset("Sunset", "🌅", listOf(0xFF41295A.toInt(), 0xFF8F4A3E.toInt(), 0xFFD76D77.toInt())), - WallpaperPreset("Deep", "🌊", listOf(0xFF000428.toInt(), 0xFF004683.toInt())), - WallpaperPreset("Charcoal", "🖤", listOf(0xFF16161A.toInt(), 0xFF232526.toInt(), 0xFF2F3437.toInt())), + WallpaperPreset("Sunset", "\uD83C\uDF05", listOf(0xFF41295A.toInt(), 0xFF8F4A3E.toInt(), 0xFFD76D77.toInt())), + WallpaperPreset("Deep", "\uD83C\uDF0A", listOf(0xFF000428.toInt(), 0xFF004683.toInt())), + WallpaperPreset("Charcoal", "\uD83D\uDC5A", listOf(0xFF16161A.toInt(), 0xFF232526.toInt(), 0xFF2F3437.toInt())), + WallpaperPreset("Ember", "\uD83D\uDD25", listOf(0xFF200122.toInt(), 0xFF6F0000.toInt(), 0xFFFF8A00.toInt())), + WallpaperPreset("Mint", "\uD83C\uDF3F", listOf(0xFF093028.toInt(), 0xFF237A57.toInt(), 0xFF86CFAF.toInt())), + WallpaperPreset("Lavender", "\uD83C\uDF38", listOf(0xFF3A1C71.toInt(), 0xFF8E54E9.toInt(), 0xFFE96443.toInt())), + WallpaperPreset("Slate", "\uD83D\uDCA8", listOf(0xFF1F1C2C.toInt(), 0xFF4A4A5A.toInt(), 0xFF8E9AAF.toInt())), + WallpaperPreset("Candy", "\uD83C\uDF6D", listOf(0xFFFF61D2.toInt(), 0xFFB44AFF.toInt(), 0xFF5A69FF.toInt())), ) /** Preset used on a fresh install (matches `LauncherConfig.wallpaper`'s default). */ const val DEFAULT_WALLPAPER_INDEX = 1 +/** True when [index] is safe to use as a [WALLPAPERS] position. */ +fun isValidWallpaperIndex(index: Int): Boolean = index in WALLPAPERS.indices + +/** + * A full launcher theme: the colours every surface on the home screen derives from. + * + * The registry is the single source of truth (same rule as [WALLPAPERS]): the Settings + * theme picker labels every entry from here, so a theme can never be advertised under a + * name it does not render. + */ +data class LauncherTheme( + val id: String, + val name: String, + val emoji: String, + /** Tile/card fill for banner-less cards and behind app art. */ + val surface: Int, + /** Home background behind the grid (when no wallpaper shows through). */ + val background: Int, + val textPrimary: Int, + val textSecondary: Int, + /** Focus ring / selection accent of this theme. */ + val accent: Int, +) { + fun displayLabel(): String = if (emoji.isEmpty()) name else "$emoji $name" +} + +val THEMES: List = listOf( + LauncherTheme("midnight", "Midnight", "\uD83C\uDF19", 0xFF141720.toInt(), 0xFF0F1420.toInt(), 0xFFE8EAED.toInt(), 0xFF9AA0A6.toInt(), 0xFF8AB4F8.toInt()), + LauncherTheme("amoled", "AMOLED Black", "⬛", 0xFF000000.toInt(), 0xFF000000.toInt(), 0xFFFFFFFF.toInt(), 0xFF9E9E9E.toInt(), 0xFF80CBC4.toInt()), + LauncherTheme("arctic", "Arctic", "❄️", 0xFF1E2430.toInt(), 0xFF141A24.toInt(), 0xFFF4F7FB.toInt(), 0xFFB6C2D2.toInt(), 0xFF78D9EC.toInt()), + LauncherTheme("cyber", "Cyberpunk", "⚡", 0xFF1A1030.toInt(), 0xFF120A22.toInt(), 0xFFFF6FD8.toInt(), 0xFFB39DDB.toInt(), 0xFFFF6FD8.toInt()), + LauncherTheme("solar", "Solar Flare", "\uD83D\uDD25", 0xFF22140E.toInt(), 0xFF180D08.toInt(), 0xFFFFE9D6.toInt(), 0xFFE0A98A.toInt(), 0xFFF28B82.toInt()), + LauncherTheme("forest", "Forest", "\uD83C\uDF3F", 0xFF0F1F16.toInt(), 0xFF0A1510.toInt(), 0xFFE3F2E8.toInt(), 0xFF9CC5A4.toInt(), 0xFF81C995.toInt()), + LauncherTheme("royal", "Royal", "\uD83D\uDC51", 0xFF171026.toInt(), 0xFF100A1C.toInt(), 0xFFEFE6FF.toInt(), 0xFFB9A6D9.toInt(), 0xFFD7AEFB.toInt()), + LauncherTheme("retro", "Retro CRT", "\uD83D\uDCFA", 0xFF0B1410.toInt(), 0xFF060C09.toInt(), 0xFFB6FFB0.toInt(), 0xFF6FBF73.toInt(), 0xFFB6FFB0.toInt()), + LauncherTheme("mono", "Monochrome", "◽", 0xFF121212.toInt(), 0xFF0A0A0A.toInt(), 0xFFFAFAFA.toInt(), 0xFFBDBDBD.toInt(), 0xFFE0E0E0.toInt()), + LauncherTheme("ocean", "Ocean", "\uD83C\uDF0A", 0xFF0A1A24.toInt(), 0xFF061018.toInt(), 0xFFE0F4FF.toInt(), 0xFF8FB8CB.toInt(), 0xFF4FC3F7.toInt()), +) + +/** Index into [THEMES] used on a fresh install. */ +const val DEFAULT_THEME_INDEX = 0 + +/** True when [index] is safe to use as a [THEMES] position. */ +fun isValidThemeIndex(index: Int): Boolean = index in THEMES.indices + /** Deterministic tile color for apps that ship no banner artwork. */ fun tileColor(pkg: String): Int { val palette = listOf( @@ -45,3 +97,13 @@ fun tileColor(pkg: String): Int { for (c in pkg) h = h * 31 + c.code return palette[((h % palette.size) + palette.size) % palette.size] } + +/** Blends [over] onto [base] with [alpha] (0..255). Used for fill-style focus effects. */ +fun blendColors(base: Int, over: Int, alpha: Int): Int { + val a = alpha.coerceIn(0, 255) + val inv = 255 - a + val r = (((base shr 16 and 0xFF) * inv + (over shr 16 and 0xFF) * a) / 255) + val g = (((base shr 8 and 0xFF) * inv + (over shr 8 and 0xFF) * a) / 255) + val b = (((base and 0xFF) * inv + (over and 0xFF) * a) / 255) + return (0xFF shl 24) or (r shl 16) or (g shl 8) or b +} diff --git a/app/src/main/java/com/gothwad/launcher/ui/dialogs/AppMenuDialogFragment.kt b/app/src/main/java/com/gothwad/launcher/ui/dialogs/AppMenuDialogFragment.kt new file mode 100644 index 0000000..5611095 --- /dev/null +++ b/app/src/main/java/com/gothwad/launcher/ui/dialogs/AppMenuDialogFragment.kt @@ -0,0 +1,142 @@ +package com.gothwad.launcher.ui.dialogs + +import android.app.Dialog +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import com.gothwad.launcher.Actions +import com.gothwad.launcher.R +import com.gothwad.launcher.data.AppEntry +import com.gothwad.launcher.data.ConfigStore +import com.gothwad.launcher.data.LauncherConfig +import kotlinx.coroutines.launch + +/** + * Quick actions for one app tile (long-press / menu key): favourite, hide, lock, app info, + * uninstall. Built programmatically - one small dialog, no layout file to keep in sync. + * + * Every change goes through [ConfigStore]; the home grid re-renders from the config flow. + */ +class AppMenuDialogFragment : DialogFragment() { + + private var app: AppEntry? = null + private var config: LauncherConfig = LauncherConfig() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = Dialog(requireContext()) + val ctx = requireContext() + val density = ctx.resources.displayMetrics.density + val current = app ?: return dialog + + val scroll = ScrollView(ctx) + val root = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + setPadding((24 * density).toInt(), (16 * density).toInt(), (24 * density).toInt(), (8 * density).toInt()) + } + scroll.addView(root) + + fun button(label: String, onClick: () -> Unit): Button = Button(ctx).apply { + text = label + setBackgroundResource(R.drawable.bg_settings_row) + stateListAnimator = null + minHeight = (48 * density).toInt() + gravity = Gravity.START or Gravity.CENTER_VERTICAL + setPadding((24 * density).toInt(), 0, (24 * density).toInt(), 0) + setTextColor(android.content.res.ColorStateList.valueOf(0xFFFFFFFF.toInt())) + textSize = 14f + val lp = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + lp.bottomMargin = (6 * density).toInt() + layoutParams = lp + setOnClickListener { + onClick() + } + } + + val store = ConfigStore(ctx) + fun update(transform: (LauncherConfig) -> LauncherConfig) { + lifecycleScope.launch { store.update(transform) } + } + + // Text: dark when focused/selected/pressed - same contract as the settings sheet. + val textStateList = androidx.core.content.ContextCompat.getColorStateList(ctx, R.color.color_settings_btn_text) + + fun styled(label: String, onClick: () -> Unit): Button = button(label, onClick).also { + if (textStateList != null) it.setTextColor(textStateList) + } + + root.addView(styled(ctx.getString(R.string.app_menu_launch)) { + dismiss() + Actions.launchApp(ctx, current.pkg) + }) + root.addView(styled(ctx.getString(R.string.app_menu_app_info)) { + dismiss() + Actions.openAppInfo(ctx, current.pkg) + }) + root.addView(styled(ctx.getString(R.string.app_menu_uninstall)) { + dismiss() + Actions.uninstall(ctx, current.pkg) + }) + + val isFav = current.pkg in config.favorites + root.addView(styled( + ctx.getString(if (isFav) R.string.app_menu_unfavourite else R.string.app_menu_favourite) + ) { + update { + it.copy( + favorites = if (isFav) it.favorites - current.pkg + else (it.favorites + current.pkg).distinct(), + showFavoritesRow = if (isFav) it.showFavoritesRow else true, + ) + } + dismiss() + }) + + val isHidden = current.pkg in config.hidden + root.addView(styled( + ctx.getString(if (isHidden) R.string.app_menu_unhide else R.string.app_menu_hide) + ) { + update { + it.copy(hidden = if (isHidden) it.hidden - current.pkg else it.hidden + current.pkg) + } + dismiss() + }) + + val isLocked = current.pkg in config.lockedApps + root.addView(styled( + ctx.getString(if (isLocked) R.string.app_menu_unlock else R.string.app_menu_lock) + ) { + update { + it.copy( + lockedApps = if (isLocked) it.lockedApps - current.pkg + else it.lockedApps + current.pkg, + ) + } + dismiss() + }) + + dialog.setContentView(scroll) + dialog.window?.setLayout( + (320 * density).toInt().coerceAtMost( + ctx.resources.displayMetrics.widthPixels - (48 * density).toInt() + ), + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + return dialog + } + + companion object { + const val TAG = "AppMenuDialog" + + fun newInstance(app: AppEntry, config: LauncherConfig): AppMenuDialogFragment = + AppMenuDialogFragment().apply { + this.app = app + this.config = config + } + } +} 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..c20d5de 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 @@ -47,12 +48,73 @@ class SettingsBottomSheetFragment : DialogFragment() { private var currentSubPage: View? = null private lateinit var wallpaperDelegate: SettingsWallpaperDelegate + private lateinit var themeDelegate: SettingsThemeDelegate private lateinit var displayDelegate: SettingsDisplayDelegate private lateinit var statusBarDelegate: SettingsStatusBarDelegate private lateinit var appsDelegate: SettingsAppsDelegate private lateinit var securityDelegate: SettingsSecurityDelegate private lateinit var buttonMappingDelegate: SettingsButtonMappingDelegate + private val backupJson = kotlinx.serialization.json.Json { + ignoreUnknownKeys = true + encodeDefaults = true + prettyPrint = true + } + + private val exportBackup = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { uri -> + if (uri != null && _binding != null && isAdded) { + val context = requireContext() + viewLifecycleOwner.lifecycleScope.launch { + val ok = withContext(Dispatchers.IO) { + runCatching { + val text = backupJson.encodeToString( + LauncherConfig.serializer(), config + ) + context.contentResolver.openOutputStream(uri)?.use { + it.write(text.toByteArray(Charsets.UTF_8)) + } ?: error("no output stream") + }.isSuccess + } + Actions.toast( + context, + context.getString( + if (ok) R.string.backup_exported else R.string.backup_failed + ) + ) + } + } + } + + private val importBackup = registerForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri != null && _binding != null && isAdded) { + val context = requireContext() + viewLifecycleOwner.lifecycleScope.launch { + val imported = withContext(Dispatchers.IO) { + runCatching { + val text = context.contentResolver.openInputStream(uri)?.use { + it.readBytes().toString(Charsets.UTF_8) + } ?: error("no input stream") + backupJson.decodeFromString(LauncherConfig.serializer(), text) + }.getOrNull() + } + if (imported != null) { + store.update { imported } + config = imported + themeDelegate.syncThemeSelection() + wallpaperDelegate.syncSelection() + updateSubtitles() + Actions.toast(context, context.getString(R.string.backup_imported)) + } else { + Actions.toast(context, context.getString(R.string.backup_failed)) + } + } + } + } + private val photoPicker = registerForActivityResult( ActivityResultContracts.OpenDocument() ) { uri -> @@ -68,6 +130,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") @@ -131,6 +194,17 @@ class SettingsBottomSheetFragment : DialogFragment() { updateSubtitles = { updateSubtitles() } ).also { it.bind() } + themeDelegate = SettingsThemeDelegate( + fragment = this, + binding = binding, + store = store, + getConfig = { config }, + updateConfig = { config = it }, + updateSubtitles = { updateSubtitles() }, + onExportBackup = { exportBackup.launch("gothwad-settings.json") }, + onImportBackup = { importBackup.launch(arrayOf("application/json")) }, + ).also { it.bind() } + displayDelegate = SettingsDisplayDelegate( fragment = this, binding = binding, @@ -186,14 +260,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() } @@ -216,6 +296,7 @@ class SettingsBottomSheetFragment : DialogFragment() { // Sub-page clicks within categories binding.rowWallpaper.setOnClickListener { navigateToSubPage(binding.pageWallpaper, getString(R.string.page_title_wallpaper)) } + binding.rowTheme.setOnClickListener { navigateToSubPage(binding.pageTheme, getString(R.string.page_title_theme)) } binding.rowDisplay.setOnClickListener { navigateToSubPage(binding.pageDisplay, getString(R.string.page_title_display)) } binding.rowStatusbar.setOnClickListener { navigateToSubPage(binding.pageStatusbar, getString(R.string.page_title_statusbar)) } binding.rowApps.setOnClickListener { navigateToSubPage(binding.pageApps, getString(R.string.page_title_apps_vault)) } @@ -243,6 +324,9 @@ class SettingsBottomSheetFragment : DialogFragment() { binding.rowWallpaper.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_wallpaper) } + binding.rowTheme.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_theme) + } binding.rowDisplay.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_display) } @@ -543,6 +627,7 @@ class SettingsBottomSheetFragment : DialogFragment() { binding.pageCatDevice -> "Manage Wi-Fi, Ethernet, system permissions, and full Android TV settings." binding.pageWallpaper -> "Select from dynamic background gradients, solid hues, or upload a custom TV wallpaper." binding.pageDisplay -> "Fine-tune app icon sizing, UI scale density, and layout geometry for your display." + binding.pageTheme -> getString(R.string.focus_desc_theme) binding.pageStatusbar -> "Customize clock time format, network indicators, Bluetooth controls, and VPN shortcuts." binding.pageApps -> "Reorder apps, manage auto-categorization, or hide private apps in the secret vault." binding.pageSecurity -> "Configure lock types, PIN entry codes, biometric access, and secure application protection." @@ -552,6 +637,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 +671,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/SettingsThemeDelegate.kt b/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsThemeDelegate.kt new file mode 100644 index 0000000..f9230f4 --- /dev/null +++ b/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsThemeDelegate.kt @@ -0,0 +1,243 @@ +package com.gothwad.launcher.ui.dialogs + +import android.widget.Button +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import com.gothwad.launcher.data.ConfigStore +import com.gothwad.launcher.data.LauncherConfig +import com.gothwad.launcher.databinding.SheetSettingsBinding +import kotlinx.coroutines.launch + +/** + * Theme & Style page: themes, accent, layout, icons, focus/animations, clock, night + * light, sorting, quick rows, performance profile and backup entry points. + * + * Every control writes through [ConfigStore] and mirrors into the fragment's local + * config copy; the home grid re-renders from the config flow, so nothing here touches + * home-screen views directly. + */ +class SettingsThemeDelegate( + private val fragment: SettingsBottomSheetFragment, + private val binding: SheetSettingsBinding, + private val store: ConfigStore, + private val getConfig: () -> LauncherConfig, + private val updateConfig: (LauncherConfig) -> Unit, + private val updateSubtitles: () -> Unit, + private val onExportBackup: () -> Unit, + private val onImportBackup: () -> Unit, +) { + + private var themeAdapter: ThemeTileAdapter? = null + + private fun persist(transform: (LauncherConfig) -> LauncherConfig) { + fragment.viewLifecycleOwner.lifecycleScope.launch { + store.update(transform) + updateConfig(transform(getConfig())) + updateSubtitles() + } + } + + /** Binds a row of mutually-exclusive buttons to an Int config value. */ + private fun bindChoice( + buttons: List>, + current: (LauncherConfig) -> Int, + apply: (LauncherConfig, Int) -> LauncherConfig, + ) { + fun sync() { + val value = current(getConfig()) + for ((btn, v) in buttons) btn.isSelected = v == value + } + sync() + for ((btn, v) in buttons) { + btn.setOnClickListener { + persist { apply(it, v) } + // Optimistic local sync so the chip flips without waiting for DataStore. + for ((b, bv) in buttons) b.isSelected = bv == v + } + } + } + + private fun bindSwitch( + row: android.view.View, + switch: com.google.android.material.switchmaterial.SwitchMaterial, + current: (LauncherConfig) -> Boolean, + apply: (LauncherConfig, Boolean) -> LauncherConfig, + ) { + switch.isChecked = current(getConfig()) + row.setOnClickListener { + val newVal = !switch.isChecked + switch.isChecked = newVal + persist { apply(it, newVal) } + } + } + + fun bind() { + val ctx = fragment.requireContext() + + // ----- themes ----- + themeAdapter = ThemeTileAdapter { index -> + persist { it.copy(themeIndex = index) } + themeAdapter?.select(index) + } + binding.recyclerThemes.layoutManager = + LinearLayoutManager(ctx, LinearLayoutManager.HORIZONTAL, false) + binding.recyclerThemes.adapter = themeAdapter + binding.recyclerThemes.itemAnimator = null + themeAdapter?.select(getConfig().themeIndex) + + bindSwitch( + binding.rowToggleFollowWallpaper, binding.switchFollowWallpaper, + { it.followWallpaperPalette }, { c, v -> c.copy(followWallpaperPalette = v) }, + ) + + // ----- accent (10 colours in ACCENTS order) ----- + bindChoice( + listOf( + binding.btnAccent0 to 0, binding.btnAccent1 to 1, binding.btnAccent2 to 2, + binding.btnAccent3 to 3, binding.btnAccent4 to 4, binding.btnAccent5 to 5, + binding.btnAccent6 to 6, binding.btnAccent7 to 7, binding.btnAccent8 to 8, + binding.btnAccent9 to 9, + ), + { it.accent }, { c, v -> c.copy(accent = v, followWallpaperPalette = false) }, + ) + + // ----- layout / columns / size ----- + bindChoice( + listOf( + binding.btnLayout0 to 0, binding.btnLayout1 to 1, + binding.btnLayout2 to 2, binding.btnLayout3 to 3, + ), + { it.layoutMode }, { c, v -> c.copy(layoutMode = v) }, + ) + bindChoice( + listOf( + binding.btnCol3 to 3, binding.btnCol4 to 4, binding.btnCol5 to 5, + binding.btnCol6 to 6, binding.btnCol7 to 7, binding.btnCol8 to 8, + ), + { it.gridColumns }, { c, v -> c.copy(gridColumns = v) }, + ) + bindChoice( + listOf( + binding.btnIconSize0 to 80, binding.btnIconSize1 to 100, + binding.btnIconSize2 to 125, binding.btnIconSize3 to 150, + ), + { it.iconSizePct }, { c, v -> c.copy(iconSizePct = v) }, + ) + + // ----- labels ----- + bindSwitch( + binding.rowToggleLabels, binding.switchLabels, + { it.showTileLabels }, { c, v -> c.copy(showTileLabels = v) }, + ) + bindChoice( + listOf( + binding.btnLabelSize0 to 11, binding.btnLabelSize1 to 13, + binding.btnLabelSize2 to 15, + ), + { it.labelSizeSp }, { c, v -> c.copy(labelSizeSp = v) }, + ) + + // ----- focus ----- + bindChoice( + listOf( + binding.btnFocus0 to 0, binding.btnFocus1 to 1, + binding.btnFocus2 to 2, binding.btnFocus3 to 3, + ), + { it.focusEffect }, { c, v -> c.copy(focusEffect = v) }, + ) + bindChoice( + listOf( + binding.btnFocusScale0 to 100, binding.btnFocusScale1 to 108, + binding.btnFocusScale2 to 120, binding.btnFocusScale3 to 135, + ), + { it.focusScalePct }, { c, v -> c.copy(focusScalePct = v) }, + ) + + // ----- animations ----- + bindChoice( + listOf( + binding.btnOpenAnim0 to 0, binding.btnOpenAnim1 to 1, + binding.btnOpenAnim2 to 2, binding.btnOpenAnim3 to 3, + ), + { it.openAnimation }, { c, v -> c.copy(openAnimation = v) }, + ) + bindChoice( + listOf( + binding.btnAnimSpeed0 to 0, binding.btnAnimSpeed1 to 1, + binding.btnAnimSpeed2 to 2, + ), + { it.animSpeed }, { c, v -> c.copy(animSpeed = v) }, + ) + bindSwitch( + binding.rowToggleReduceMotion, binding.switchReduceMotion, + { it.reduceMotion }, { c, v -> c.copy(reduceMotion = v) }, + ) + + // ----- clock / date / greeting ----- + bindChoice( + listOf( + binding.btnClock0 to 0, binding.btnClock1 to 1, binding.btnClock2 to 2, + ), + { it.clockStyle }, { c, v -> c.copy(clockStyle = v) }, + ) + bindChoice( + listOf( + binding.btnDate0 to 0, binding.btnDate1 to 1, + binding.btnDate2 to 2, binding.btnDate3 to 3, + ), + { it.dateStyle }, { c, v -> c.copy(dateStyle = v) }, + ) + bindSwitch( + binding.rowToggleGreeting, binding.switchGreeting, + { it.showGreeting }, { c, v -> c.copy(showGreeting = v) }, + ) + + // ----- night light ----- + bindSwitch( + binding.rowToggleNightLight, binding.switchNightLight, + { it.nightLight }, { c, v -> c.copy(nightLight = v) }, + ) + bindChoice( + listOf( + binding.btnNight0 to 20, binding.btnNight1 to 40, binding.btnNight2 to 70, + ), + { it.nightLightStrength }, { c, v -> c.copy(nightLightStrength = v) }, + ) + + // ----- sorting ----- + bindChoice( + listOf( + binding.btnSort0 to 0, binding.btnSort1 to 1, + binding.btnSort2 to 2, binding.btnSort3 to 3, + ), + { it.appSort }, { c, v -> c.copy(appSort = v) }, + ) + + // ----- quick rows ----- + bindSwitch( + binding.rowToggleFavRow, binding.switchFavRow, + { it.showFavoritesRow }, { c, v -> c.copy(showFavoritesRow = v) }, + ) + bindSwitch( + binding.rowToggleRecentRow, binding.switchRecentRow, + { it.showRecentRow }, { c, v -> c.copy(showRecentRow = v) }, + ) + + // ----- performance ----- + bindChoice( + listOf( + binding.btnPerf0 to 0, binding.btnPerf1 to 1, binding.btnPerf2 to 2, + ), + { it.perfProfile }, { c, v -> c.copy(perfProfile = v) }, + ) + + // ----- backup ----- + binding.btnBackupExport.setOnClickListener { onExportBackup() } + binding.btnBackupImport.setOnClickListener { onImportBackup() } + } + + /** Re-syncs theme tiles after an import replaces the whole config. */ + fun syncThemeSelection() { + themeAdapter?.select(getConfig().themeIndex) + } +} 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/ThemeTileAdapter.kt b/app/src/main/java/com/gothwad/launcher/ui/dialogs/ThemeTileAdapter.kt new file mode 100644 index 0000000..2733d26 --- /dev/null +++ b/app/src/main/java/com/gothwad/launcher/ui/dialogs/ThemeTileAdapter.kt @@ -0,0 +1,56 @@ +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.databinding.ItemWallpaperTileBinding +import com.gothwad.launcher.ui.THEMES + +/** + * Visual theme tiles: each tile renders the theme's own surface/background colours, so + * the user picks a look, not a name. Reuses the wallpaper tile layout. + */ +class ThemeTileAdapter( + private val onSelect: (index: Int) -> Unit, +) : RecyclerView.Adapter() { + + var selectedIndex: Int = 0 + + fun select(index: Int) { + val old = selectedIndex + selectedIndex = index + if (old in THEMES.indices) notifyItemChanged(old) + if (index in THEMES.indices) notifyItemChanged(index) + } + + inner class Holder(private val binding: ItemWallpaperTileBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(index: Int) { + val theme = THEMES[index] + val density = binding.root.resources.displayMetrics.density + binding.tileName.text = theme.displayLabel() + binding.tileGradient.background = GradientDrawable( + GradientDrawable.Orientation.TL_BR, + intArrayOf(theme.surface, theme.background, theme.accent), + ).apply { cornerRadius = 8 * density } + binding.tileCheck.visibility = + if (index == selectedIndex) View.VISIBLE else View.GONE + binding.root.setOnClickListener { onSelect(index) } + binding.root.setOnFocusChangeListener { _, hasFocus -> + val gd = binding.tileGradient.background as? GradientDrawable ?: return@setOnFocusChangeListener + if (hasFocus) gd.setStroke((2.5f * density).toInt(), 0xFFFFFFFF.toInt()) + else gd.setStroke(0, 0) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder = + Holder(ItemWallpaperTileBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + + override fun getItemCount(): Int = THEMES.size + + override fun onBindViewHolder(holder: Holder, position: Int) = holder.bind(position) +} 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/java/com/gothwad/launcher/ui/tv/TvLauncherFragment.kt b/app/src/main/java/com/gothwad/launcher/ui/tv/TvLauncherFragment.kt index c303b16..916421f 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/tv/TvLauncherFragment.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/tv/TvLauncherFragment.kt @@ -16,6 +16,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.gothwad.launcher.Actions import com.gothwad.launcher.data.AppEntry @@ -30,6 +31,7 @@ import com.gothwad.launcher.ui.AppLockGate import com.gothwad.launcher.ui.DEFAULT_WALLPAPER_INDEX import com.gothwad.launcher.ui.DensityAdapter import com.gothwad.launcher.ui.WALLPAPERS +import com.gothwad.launcher.ui.dialogs.AppMenuDialogFragment import com.gothwad.launcher.ui.dialogs.SetupWizardDialogFragment import com.gothwad.launcher.ui.view.AppCardAdapter import kotlinx.coroutines.Dispatchers @@ -48,6 +50,10 @@ class TvLauncherFragment : Fragment() { val binding get() = _binding!! private var appCardAdapter: AppCardAdapter? = null + private var quickRowAdapter: AppCardAdapter? = null + + /** Accent sampled from the current wallpaper when `followWallpaperPalette` is on. */ + private var wallpaperPaletteColor: Int? = null private var currentConfig: LauncherConfig = LauncherConfig() private var allApps: List = emptyList() @@ -90,32 +96,72 @@ class TvLauncherFragment : Fragment() { val horizontalPaddingPx = (48 * 2 * density).toInt() // 48dp on each side val availableWidthPx = screenWidthPx - horizontalPaddingPx - ((columns - 1) * gapPx) val cardWidth = (availableWidthPx / columns).coerceAtLeast((60 * density).toInt()) - val cardHeight = (cardWidth * 9f / 16f).toInt() + val mode = currentConfig.layoutMode.coerceIn(0, 3) + val aspect = when (mode) { + 1 -> 3f / 4f // compact: slightly taller than wide + 3 -> 1f // square icon grid + else -> 9f / 16f + } + // Card width in grid mode is MATCH_PARENT (columns define it); height carries the + // layout mode's shape and the user's icon-size multiplier. + val sizePct = currentConfig.iconSizePct.coerceIn(60, 160) / 100f + val baseHeight = when (mode) { + 2 -> (cardWidth * 9f / 16f * 1.25f) // large tiles + 1 -> (cardWidth * 4f / 3f * 0.62f) // compact keeps tiles small + 3 -> cardWidth.toFloat() // square + else -> (cardWidth * 9f / 16f) + } + val cardHeight = (baseHeight * sizePct).toInt().coerceAtLeast((48 * density).toInt()) return Pair(cardWidth, cardHeight) } + /** Columns for the current layout mode (grid modes honour `config.gridColumns`). */ + private fun gridSpanCount(): Int { + val base = currentConfig.gridColumns.coerceIn(3, 8) + return when (currentConfig.layoutMode.coerceIn(0, 3)) { + 1 -> base + 2 + 2 -> minOf(base, 4) + 3 -> base + 1 + else -> base + } + } + + /** Focus/hover animation duration from the anim-speed + reduced-motion settings. */ + private fun animDurationMs(): Int = when { + currentConfig.reduceMotion || currentConfig.perfProfile == 0 -> 0 + currentConfig.animSpeed == 0 -> 240 + currentConfig.animSpeed == 2 -> 90 + else -> 150 + } + private fun setupRecyclerView() { val density = resources.displayMetrics.density val gapPx = (GAP_SIZES.getOrElse(currentConfig.spacing.coerceIn(0, GAP_SIZES.size - 1)) { GAP_SIZES[2] } * density).toInt() - val spanCount = 6 + val spanCount = gridSpanCount() val (defaultWidthPx, defaultHeightPx) = calculateCardDimensions(gapPx, density, spanCount) val defaultRadiusPx = (CORNER_RADII.getOrElse(currentConfig.cornerRadius.coerceIn(0, CORNER_RADII.size - 1)) { CORNER_RADII[2] } * density) - val accentColor = ACCENTS.getOrElse(currentConfig.accent.coerceIn(0, ACCENTS.size - 1)) { ACCENTS[0] } + val accentColor = accentColor() + val theme = theme() appCardAdapter = AppCardAdapter( cardWidthPx = defaultWidthPx, cardHeightPx = defaultHeightPx, cornerRadiusPx = defaultRadiusPx, accentColor = accentColor, - showLabels = true, + showLabels = currentConfig.showTileLabels, isGridMode = true, lockedPackages = currentConfig.lockedApps, movingPackage = null, + themeSurface = theme.surface, + focusScalePct = currentConfig.focusScalePct.coerceIn(100, 140), + focusEffect = currentConfig.focusEffect.coerceIn(0, 3), + animDurationMs = animDurationMs(), + labelSizeSp = currentConfig.labelSizeSp.coerceIn(10, 16), onLaunchApp = { app -> handleAppLaunch(app) }, onAppMenu = { app -> - openAppDetails(app.pkg) + openAppMenu(app) } ) @@ -139,6 +185,50 @@ class TvLauncherFragment : Fragment() { } }) } + + setupQuickRow() + } + + /** + * The favourites + recents strip above the grid. One horizontal list, same card visuals + * as the grid (fixed card size), hidden entirely when both toggles are off. + */ + private fun setupQuickRow() { + quickRowAdapter = AppCardAdapter( + cardWidthPx = (150 * resources.displayMetrics.density).toInt(), + cardHeightPx = (84 * resources.displayMetrics.density).toInt(), + cornerRadiusPx = (CORNER_RADII.getOrElse(currentConfig.cornerRadius.coerceIn(0, CORNER_RADII.size - 1)) { CORNER_RADII[2] } * resources.displayMetrics.density), + accentColor = accentColor(), + showLabels = true, + isGridMode = false, + lockedPackages = currentConfig.lockedApps, + movingPackage = null, + themeSurface = theme().surface, + focusScalePct = currentConfig.focusScalePct.coerceIn(100, 140), + focusEffect = currentConfig.focusEffect.coerceIn(0, 3), + animDurationMs = animDurationMs(), + labelSizeSp = currentConfig.labelSizeSp.coerceIn(10, 16), + onLaunchApp = { app -> handleAppLaunch(app) }, + onAppMenu = { app -> openAppMenu(app) }, + ) + binding.recyclerQuickRow.apply { + layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) + adapter = quickRowAdapter + itemAnimator = null + } + } + + private fun theme(): com.gothwad.launcher.ui.LauncherTheme = + com.gothwad.launcher.ui.THEMES.getOrElse(currentConfig.themeIndex.coerceIn(0, com.gothwad.launcher.ui.THEMES.size - 1)) { + com.gothwad.launcher.ui.THEMES[0] + } + + /** Accent colour: theme accent, or the user's pick, or a wallpaper-derived palette colour. */ + private fun accentColor(): Int { + if (currentConfig.followWallpaperPalette) { + wallpaperPaletteColor?.let { return it } + } + return ACCENTS.getOrElse(currentConfig.accent.coerceIn(0, ACCENTS.size - 1)) { ACCENTS[0] } } private fun handleAppLaunch(app: AppEntry) { @@ -164,6 +254,14 @@ class TvLauncherFragment : Fragment() { } catch (_: Exception) {} } + /** Long-press / menu-key quick actions for an app tile. */ + private fun openAppMenu(app: AppEntry) { + if (!isAdded) return + // The dialog writes ConfigStore directly; the config flow re-renders the grid. + AppMenuDialogFragment.newInstance(app = app, config = currentConfig) + .show(parentFragmentManager, AppMenuDialogFragment.TAG) + } + private fun observeData() { val configStore = ConfigStore(requireContext()) @@ -230,6 +328,7 @@ class TvLauncherFragment : Fragment() { viewBinding.imgWallpaper.setImageBitmap(bmp) viewBinding.imgWallpaper.visibility = View.VISIBLE appliedWallpaperKey = key + updateWallpaperPalette(sampleBitmapColor(bmp)) } } else { // Also the fallback when a custom wallpaper is configured but its file is @@ -245,6 +344,7 @@ class TvLauncherFragment : Fragment() { ) viewBinding.imgWallpaper.visibility = View.VISIBLE appliedWallpaperKey = key + updateWallpaperPalette(averageColor(preset.colors)) } } @@ -283,6 +383,39 @@ class TvLauncherFragment : Fragment() { ) }.getOrNull() + /** Average colour of a gradient preset (cheap palette for palette-accent mode). */ + private fun averageColor(colors: List): Int { + if (colors.isEmpty()) return ACCENTS[0] + var r = 0L; var g = 0L; var b = 0L + for (c in colors) { + r += (c shr 16 and 0xFF); g += (c shr 8 and 0xFF); b += (c and 0xFF) + } + val n = colors.size + return (0xFF shl 24) or ((r / n).toInt() shl 16) or ((g / n).toInt() shl 8) or (b / n).toInt() + } + + /** Samples a 16x16 downscale of [bmp] and averages it — on the caller's (main) frame, + this is 256 getPixel calls, so it's only run when the wallpaper actually changes. */ + private fun sampleBitmapColor(bmp: Bitmap): Int { + val small = Bitmap.createScaledBitmap(bmp, 16, 16, true) + var r = 0L; var g = 0L; var b = 0L + for (x in 0 until small.width) { + for (y in 0 until small.height) { + val p = small.getPixel(x, y) + r += (p shr 16 and 0xFF); g += (p shr 8 and 0xFF); b += (p and 0xFF) + } + } + val n = (small.width * small.height).coerceAtLeast(1) + if (small !== bmp) small.recycle() + return (0xFF shl 24) or ((r / n).toInt() shl 16) or ((g / n).toInt() shl 8) or (b / n).toInt() + } + + private fun updateWallpaperPalette(color: Int) { + if (wallpaperPaletteColor == color) return + wallpaperPaletteColor = color + if (currentConfig.followWallpaperPalette) updateDimensionsAndStyling() + } + fun onRescanRequested() { if (_binding == null || !isAdded) return viewLifecycleOwner.lifecycleScope.launch { @@ -301,20 +434,48 @@ class TvLauncherFragment : Fragment() { private fun updateDimensionsAndStyling() { val density = resources.displayMetrics.density val gapPx = (GAP_SIZES.getOrElse(currentConfig.spacing.coerceIn(0, GAP_SIZES.size - 1)) { GAP_SIZES[2] } * density).toInt() - val spanCount = 6 + val spanCount = gridSpanCount() val (widthPx, heightPx) = calculateCardDimensions(gapPx, density, spanCount) val radiusPx = (CORNER_RADII.getOrElse(currentConfig.cornerRadius.coerceIn(0, CORNER_RADII.size - 1)) { CORNER_RADII[2] } * density) - val accentColor = ACCENTS.getOrElse(currentConfig.accent.coerceIn(0, ACCENTS.size - 1)) { ACCENTS[0] } + val accentColor = accentColor() + val theme = theme() + + // Column count can change (layout mode / gridColumns) - the decoration and the + // layout manager must be rebuilt, not just restyled. + val glm = binding.recyclerCategories.layoutManager as? GridLayoutManager + if (glm != null && glm.spanCount != spanCount) { + glm.spanCount = spanCount + } appCardAdapter?.updateConfig( widthPx = widthPx, heightPx = heightPx, radiusPx = radiusPx, accent = accentColor, - labels = true, + labels = currentConfig.showTileLabels, gridMode = true, locked = currentConfig.lockedApps, - moving = null + moving = null, + themeSurface = theme.surface, + focusScalePct = currentConfig.focusScalePct.coerceIn(100, 140), + focusEffect = currentConfig.focusEffect.coerceIn(0, 3), + animDurationMs = animDurationMs(), + labelSizeSp = currentConfig.labelSizeSp.coerceIn(10, 16), + ) + quickRowAdapter?.updateConfig( + widthPx = (150 * density).toInt(), + heightPx = (84 * density).toInt(), + radiusPx = radiusPx, + accent = accentColor, + labels = true, + gridMode = false, + locked = currentConfig.lockedApps, + moving = null, + themeSurface = theme.surface, + focusScalePct = currentConfig.focusScalePct.coerceIn(100, 140), + focusEffect = currentConfig.focusEffect.coerceIn(0, 3), + animDurationMs = animDurationMs(), + labelSizeSp = currentConfig.labelSizeSp.coerceIn(10, 16), ) } @@ -325,20 +486,59 @@ class TvLauncherFragment : Fragment() { if (!hasLoadedApps) return val visible = if (currentConfig.showHidden) allApps else allApps.filter { it.pkg !in currentConfig.hidden } + + // Sort mode (0 = name A-Z, 1 = install date, 2 = recently updated, 3 = manual). + val baseSorted = when (currentConfig.appSort.coerceIn(0, 3)) { + 1 -> visible.sortedBy { it.firstInstall } + 2 -> visible.sortedByDescending { it.stamp } + 3 -> visible.sortedBy { it.firstInstall } + else -> visible.sortedBy { it.label.lowercase() } + } + val ordered = if (currentConfig.order.isNotEmpty()) { val explicit = currentConfig.order.values.flatten() if (explicit.isNotEmpty()) { - val byPkg = visible.associateBy { it.pkg } + val byPkg = baseSorted.associateBy { it.pkg } val orderedFirst = explicit.mapNotNull { byPkg[it] } - val remaining = visible.filter { it.pkg !in explicit.toSet() } + val remaining = baseSorted.filter { it.pkg !in explicit.toSet() } orderedFirst + remaining } else { - visible + baseSorted } } else { - visible + baseSorted } appCardAdapter?.submitList(ordered) + rebuildQuickRow() + } + + /** Favourites first, then recently launched apps (both honour their settings toggles). */ + private fun rebuildQuickRow() { + val adapter = quickRowAdapter ?: return + val byPkg = allApps.associateBy { it.pkg } + val items = LinkedHashMap() + if (currentConfig.showFavoritesRow) { + for (pkg in currentConfig.favorites) { + byPkg[pkg]?.let { items[pkg] = it } + } + } + if (currentConfig.showRecentRow) { + for (pkg in com.gothwad.launcher.data.AppLaunchTracker.recent()) { + if (items.size >= 12) break + if (pkg !in items) byPkg[pkg]?.let { items[pkg] = it } + } + } + val list = items.values.toList() + binding.recyclerQuickRow.visibility = if (list.isEmpty()) View.GONE else View.VISIBLE + // The grid slides down when the strip is visible so tiles never sit under it. + val density = resources.displayMetrics.density + binding.recyclerCategories.setPadding( + binding.recyclerCategories.paddingLeft, + if (list.isEmpty()) (84 * density).toInt() else (196 * density).toInt(), + binding.recyclerCategories.paddingRight, + binding.recyclerCategories.paddingBottom, + ) + adapter.submitList(list) } fun scrollToTop() { diff --git a/app/src/main/java/com/gothwad/launcher/ui/view/AppCardAdapter.kt b/app/src/main/java/com/gothwad/launcher/ui/view/AppCardAdapter.kt index b557dc7..bc6ac04 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/view/AppCardAdapter.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/view/AppCardAdapter.kt @@ -14,6 +14,7 @@ import androidx.recyclerview.widget.RecyclerView import com.gothwad.launcher.data.AppEntry import com.gothwad.launcher.databinding.ItemAppCardBinding import com.gothwad.launcher.ui.AppIcons +import com.gothwad.launcher.ui.blendColors class AppCardAdapter( private var cardWidthPx: Int, @@ -24,6 +25,16 @@ class AppCardAdapter( private var isGridMode: Boolean = true, private var lockedPackages: Set = emptySet(), private var movingPackage: String? = null, + /** Theme tile fill (behind art / banner-less cards). */ + private var themeSurface: Int = 0xFF141720.toInt(), + /** Focus scale in percent (100..140). */ + private var focusScalePct: Int = 108, + /** 0 = scale, 1 = glow, 2 = outline, 3 = fill. */ + private var focusEffect: Int = 0, + /** Focus/hover animation duration in ms (0 = instant). */ + private var animDurationMs: Int = 150, + /** Tile label size in sp. */ + private var labelSizeSp: Int = 12, private val onLaunchApp: (AppEntry) -> Unit, private val onAppMenu: (AppEntry) -> Unit, ) : ListAdapter(AppCardDiffCallback()) { @@ -46,9 +57,17 @@ class AppCardAdapter( gridMode: Boolean = true, locked: Set, moving: String?, + themeSurface: Int = 0xFF141720.toInt(), + focusScalePct: Int = 108, + focusEffect: Int = 0, + animDurationMs: Int = 150, + labelSizeSp: Int = 12, ) { val sizeChanged = cardWidthPx != widthPx || cardHeightPx != heightPx || isGridMode != gridMode - val visualChanged = cornerRadiusPx != radiusPx || accentColor != accent || showLabels != labels + val visualChanged = cornerRadiusPx != radiusPx || accentColor != accent || showLabels != labels || + this.themeSurface != themeSurface || this.focusScalePct != focusScalePct || + this.focusEffect != focusEffect || this.animDurationMs != animDurationMs || + this.labelSizeSp != labelSizeSp cardWidthPx = widthPx cardHeightPx = heightPx cornerRadiusPx = radiusPx @@ -57,6 +76,11 @@ class AppCardAdapter( isGridMode = gridMode lockedPackages = locked movingPackage = moving + this.themeSurface = themeSurface + this.focusScalePct = focusScalePct + this.focusEffect = focusEffect + this.animDurationMs = animDurationMs + this.labelSizeSp = labelSizeSp if (!sizeChanged && !visualChanged) return // Never notify while RecyclerView is mid-layout/scroll: that throws @@ -221,15 +245,19 @@ class AppCardAdapter( private fun applyVisualState(hasFocus: Boolean, hovered: Boolean, app: AppEntry?) { val isMoving = app != null && app.pkg == movingPackage + val scaleEffect = focusEffect == 0 || focusEffect == 1 + val focusScale = 1f + (focusScalePct - 100) / 100f val targetScale = when { - isMoving -> 1.05f - hasFocus -> 1.08f + isMoving -> focusScale + hasFocus && scaleEffect -> focusScale + hasFocus -> 1.03f hovered -> 1.03f else -> 1.0f } + val glowBoost = if (focusEffect == 1) 1.8f else 1f val targetElevation = when { - hasFocus -> 12f * itemView.resources.displayMetrics.density + hasFocus -> 12f * glowBoost * itemView.resources.displayMetrics.density hovered -> 6f * itemView.resources.displayMetrics.density else -> 0f } @@ -238,7 +266,7 @@ class AppCardAdapter( .scaleX(targetScale) .scaleY(targetScale) .translationZ(targetElevation) - .setDuration(150) + .setDuration(animDurationMs.toLong()) .setInterpolator(DecelerateInterpolator()) .withLayer() .start() @@ -252,28 +280,54 @@ class AppCardAdapter( hovered: Boolean, isMoving: Boolean ) { - val tileColor = if (app != null) { - if (app.banner != null) 0xFF141720.toInt() else 0xFF181B26.toInt() - } else 0xFF212638.toInt() + val baseFill = when { + app != null && app.banner != null -> blendColors(themeSurface, 0xFF000000.toInt(), 60) + app != null -> blendColors(themeSurface, 0xFFFFFFFF.toInt(), 16) + else -> blendColors(themeSurface, 0xFFFFFFFF.toInt(), 30) + } val density = itemView.resources.displayMetrics.density - val strokeWidth = when { - isMoving -> 3.5f * density - hasFocus -> 2.5f * density - hovered -> 1.5f * density - else -> 0f + val strokeWidth: Float + val strokeColor: Int + val fillColor: Int = when { + // Fill effect: the whole card tints toward the accent while focused. + focusEffect == 3 && (hasFocus || isMoving) -> blendColors(baseFill, accentColor, 70) + else -> baseFill } - val strokeColor = when { - isMoving || hasFocus -> accentColor - hovered -> (accentColor and 0x00FFFFFF) or 0xCC000000.toInt() - else -> 0 + when { + isMoving -> { + strokeWidth = 3.5f * density + strokeColor = accentColor + } + hasFocus && focusEffect == 1 -> { + // Glow: thin bright edge; the elevated translationZ does the rest. + strokeWidth = 1.5f * density + strokeColor = 0xFFFFFFFF.toInt() + } + hasFocus && focusEffect == 3 -> { + strokeWidth = 1.5f * density + strokeColor = accentColor + } + hasFocus -> { + // 0 (scale) and 2 (outline) share the accent outline. + strokeWidth = if (focusEffect == 2) 3.5f * density else 2.5f * density + strokeColor = accentColor + } + hovered -> { + strokeWidth = 1.5f * density + strokeColor = (accentColor and 0x00FFFFFF) or 0xCC000000.toInt() + } + else -> { + strokeWidth = 0f + strokeColor = 0 + } } var drawable = cardDrawable if (drawable == null) { drawable = SmoothCornerDrawable( cornerRadiusPx = cornerRadiusPx, - fillColor = tileColor, + fillColor = fillColor, strokeColor = strokeColor, strokeWidthPx = strokeWidth, ) @@ -281,7 +335,7 @@ class AppCardAdapter( binding.cardInner.background = drawable } else { drawable.cornerRadiusPx = cornerRadiusPx - drawable.fillColor = tileColor + drawable.fillColor = fillColor drawable.strokeColor = strokeColor drawable.strokeWidthPx = strokeWidth } @@ -306,8 +360,17 @@ class AppCardAdapter( updateCardBackground(app, itemView.isFocused, isHovered, app != null && app.pkg == movingPackage) if (app != null && app.banner == null) { binding.txtLabel.text = app.label - binding.txtLabel.visibility = View.VISIBLE } + applyLabelStyle() + } + + private fun applyLabelStyle() { + binding.txtLabel.setTextSize( + android.util.TypedValue.COMPLEX_UNIT_SP, + labelSizeSp.toFloat(), + ) + binding.txtLabel.visibility = + if (showLabels) View.VISIBLE else View.GONE } private fun applyDimensions() { @@ -343,8 +406,8 @@ class AppCardAdapter( binding.imgIcon.visibility = View.GONE } binding.txtLabel.text = app.label - binding.txtLabel.visibility = View.VISIBLE } + applyLabelStyle() } } 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/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0907193..7ad1565 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -24,4 +24,18 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + diff --git a/app/src/main/res/layout/fragment_tv_launcher.xml b/app/src/main/res/layout/fragment_tv_launcher.xml index d3fcf6e..c40b404 100644 --- a/app/src/main/res/layout/fragment_tv_launcher.xml +++ b/app/src/main/res/layout/fragment_tv_launcher.xml @@ -1,5 +1,6 @@ - + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/sheet_settings.xml b/app/src/main/res/layout/sheet_settings.xml index 10177bb..fcee43f 100644 --- a/app/src/main/res/layout/sheet_settings.xml +++ b/app/src/main/res/layout/sheet_settings.xml @@ -441,6 +441,38 @@ android:contentDescription="Next" /> + + + + + + + + -