Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
60 changes: 59 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`
Expand All @@ -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.
Expand All @@ -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.
Expand Down
35 changes: 30 additions & 5 deletions app/src/main/java/com/gothwad/launcher/GothwadApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
}

Expand Down
44 changes: 29 additions & 15 deletions app/src/main/java/com/gothwad/launcher/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ object AppLaunchTracker {
}
}

/** Snapshot of recently launched packages, most recent first (for the quick row). */
fun recent(limit: Int = 8): List<String> = synchronized(lock) {
lruList.take(limit)
}

/**
* Executes memory trimming on a background thread.
*
Expand Down
Loading
Loading