diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..1e2440e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,148 @@ +name: Bug report +description: Something is broken, crashing, or behaving incorrectly on a device +title: "[Bug] " +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report this. Gothwad Launcher runs on TV boxes and set-top + boxes whose firmware differs far more than phone firmware does, so the device details below + are **not** optional boilerplate — they usually decide whether a bug is reproducible. + + Before filing, check [docs/TROUBLESHOOTING.md](https://github.com/GothwadTech/launcher/blob/main/docs/TROUBLESHOOTING.md): + boot ads, safe-mode toasts, giant/tiny UI, "app lock doesn't work from recents", "Quick + Dashboard says Remote: unknown" and self-update install failures are all covered there. + + - type: textarea + id: summary + attributes: + label: What happened? + description: One or two sentences describing the incorrect behaviour. + placeholder: After the box wakes from standby, the home grid is empty until I press Back. + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: The exact sequence. Include anything you pressed on the remote. + placeholder: | + 1. Set the launcher as default home + 2. Press power to standby + 3. Press power to wake + 4. Grid is empty + value: | + 1. + 2. + 3. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect instead? + validations: + required: true + + - type: input + id: device + attributes: + label: Device, Android version and build + description: | + The box/TV model (not "an Android TV") plus Settings → About. Include the firmware or + build number if you can find it — it often decides whether a bug is reproducible. + placeholder: Xiaomi Mi Box S 2nd gen, Android 11, build QTG1.200615.002 + validations: + required: true + + - type: dropdown + id: install + attributes: + label: How was the launcher installed? + description: Debug and release builds are signed with different keys and behave differently on update. + options: + - GitHub Release APK (signed release) + - Google Play Store + - F-Droid + - Self-built debug APK (./gradlew assembleDebug) + - Self-built release APK (-PlocalSign or own keystore) + - Pre-installed by the vendor + validations: + required: true + + - type: input + id: version + attributes: + label: Launcher version + description: Settings → About shows it. + placeholder: 1.0.6 (versionCode 10006) + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Which area is affected? + multiple: true + options: + - Home grid / app list + - Status bar + - Wallpapers & appearance + - App lock / hidden apps (vault) + - Quick Dashboard + - Search / voice search + - Settings sheet + - Remote button mapping + - Boot behaviour / boot ads / watchdog + - Self-update + - Performance / memory / crashes + - Other + + - type: dropdown + id: a11y + attributes: + label: Is the accessibility service enabled? + description: "Settings → Accessibility → Gothwad Launcher. Lock enforcement outside the home grid depends on it." + options: + - "Yes" + - "No" + - "It turns itself off after a reboot" + - "Not sure how to check" + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs + description: | + Run these and paste the output inside the code fences. `run-as` needs a debuggable build. + + ``` + adb shell run-as com.gothwad.launcher cat files/gothwad_crash.log + adb logcat -d | grep -iE "gothwad|AndroidRuntime" + ``` + render: shell + + - type: textarea + id: media + attributes: + label: Screen recording or photos + description: | + For anything visual (rendering, scaling, flicker, overlays) a photo of the TV is more useful + than a description. Drag files in here. + + - type: checkboxes + id: terms + attributes: + label: Final checks + options: + - label: I checked docs/TROUBLESHOOTING.md and this is not already covered there + required: true + - label: I searched existing issues (open and closed) for this symptom + required: true + - label: This report contains no credentials, PINs or reveal codes + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..dd3d5ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,21 @@ +blank_issues_enabled: false +contact_links: + - name: Troubleshooting guide + url: https://github.com/GothwadTech/launcher/blob/main/docs/TROUBLESHOOTING.md + about: > + Boot ads, safe-mode toasts, wrong UI size, app lock not working from recents, + "Remote: unknown" in the dashboard, self-update install failures — read this first. + - name: Roadmap & known gaps + url: https://github.com/GothwadTech/launcher/blob/main/docs/ROADMAP.md + about: Already-planned work, why it has not landed, and the explicit non-goals. + - name: Configuration reference + url: https://github.com/GothwadTech/launcher/blob/main/docs/CONFIG.md + about: Every persisted setting — type, default, what reads it, which screen writes it. + - name: Security & privacy + url: https://github.com/GothwadTech/launcher/blob/main/docs/SECURITY.md + about: > + How locks and credentials work, what each permission is for, and what is NOT collected. + For a lock bypass or credential leak, do not open a public issue — contact the maintainers. + - name: Contributing guide + url: https://github.com/GothwadTech/launcher/blob/main/CONTRIBUTING.md + about: Ground rules (no Compose, no main-thread blocking), PR checklist, local build commands. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..4f0508d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,88 @@ +name: Feature request +description: Suggest a new capability or an improvement +title: "[Feature] " +labels: ["enhancement", "triage"] +body: + - type: markdown + attributes: + value: | + Please read [docs/ROADMAP.md](https://github.com/GothwadTech/launcher/blob/main/docs/ROADMAP.md) + first — your idea may already be scoped there, including the reason it has not been built + yet. Note the explicit non-goals: Jetpack Compose, cloud sync or accounts, analytics, and + content widgets (weather, scores) will not be accepted. + + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: Describe the situation, not the solution. "I can't reach X from the couch" beats "add a button". + placeholder: After the box wakes from standby I have to scroll the whole grid to find the app I was using. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What would you like to happen? + description: Describe the behaviour you want, including where it lives in the UI and how it is reached with a remote. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives you considered or workarounds you use today + description: Include other launchers or apps that do this well, and what they do differently. + + - type: dropdown + id: area + attributes: + label: Which area would this touch? + multiple: true + options: + - Home grid / app list + - Status bar + - Wallpapers & appearance + - App lock / hidden apps (vault) + - Quick Dashboard + - Search / voice search + - Settings sheet + - Remote button mapping + - Boot behaviour / watchdog + - Self-update + - Performance / memory + - New area entirely + + - type: dropdown + id: cost + attributes: + label: What would this cost on a 2 GB box? + description: | + Every feature pays in memory, startup time or a new permission. Being honest here speeds up + triage considerably — "I don't know" is a fine answer. + options: + - No new permission, no measurable memory or startup cost + - Needs a new permission (say which in the notes) + - Needs extra memory or a bigger startup path + - Needs network access beyond the existing update check + - I don't know + validations: + required: true + + - type: textarea + id: notes + attributes: + label: Notes + description: Permissions required, firmware quirks you have seen, mockups, links. + + - type: checkboxes + id: terms + attributes: + label: Final checks + options: + - label: I checked docs/ROADMAP.md and this is not already listed or explicitly a non-goal + required: true + - label: I searched existing issues (open and closed) for this request + required: true + - label: This feature can be built with native Android Views — no Jetpack Compose + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..6dc788d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,55 @@ + + +## What does this change? + + + +## Why? + + + +## How did you verify it? + + + +- Device / firmware: +- Build installed (`assembleDebug`, GitHub Release APK, …): +- What you did and what happened: +- Unit tests added / run: + +## Screenshots or recordings + + + +## Checklist + +- [ ] **No Jetpack Compose** — no `androidx.compose.*` import, no `@Composable`, no + `ComposeView`, no `buildFeatures { compose = true }` (AGENTS.md) +- [ ] No blocking work added to the main thread (no PackageManager queries, bitmap decode or + `Runtime.exec` outside a background dispatcher) +- [ ] Only the main process touches `ConfigStore`; any cross-process state goes through + `ProcessHeartbeat` in device-protected storage +- [ ] Security decisions stay in their single owner (`AppLockGate`, the accessibility service, + `LockSecurity`) and fail **secure**, never open +- [ ] No invented device state — unknown is reported as unknown +- [ ] New/changed `LauncherConfig` fields documented in `docs/CONFIG.md` (and defaulted, never + nullable) +- [ ] `CHANGELOG.md` updated under **Unreleased** +- [ ] User-visible strings are resources in `values/strings.xml` (docs/I18N.md) +- [ ] Newer-than-API-21 calls are guarded (`minSdk 21`) +- [ ] `./gradlew assembleDebug testDebugUnitTest lintDebug` passes locally +- [ ] `./gradlew assembleRelease` passes (R8/minification is a separate failure mode) +- [ ] Docs that this change invalidates were updated (`docs/ARCHITECTURE.md`, `docs/SECURITY.md`, + `docs/PERFORMANCE.md`, `README.md`) + +## Notes for the reviewer + + diff --git a/.github/actions/prepare-signing/action.yml b/.github/actions/prepare-signing/action.yml new file mode 100644 index 0000000..c60a092 --- /dev/null +++ b/.github/actions/prepare-signing/action.yml @@ -0,0 +1,94 @@ +name: Prepare release signing +description: > + Decodes the release keystore from repository secrets and exports KEYSTORE_FILE, + KEYSTORE_PASSWORD, KEY_ALIAS and KEY_PASSWORD for the rest of the job. app/build.gradle.kts + picks those up at configuration time and lets the Android Gradle Plugin sign the APK and the + AAB itself (v1 + v2 + v3), which replaces the old apksigner/jarsigner shell steps - jarsigner + only produces a v1 signature, which is not what the Play Store wants for an AAB. + + When no keystore secret is configured the step is a no-op and the artifacts stay unsigned + (that is what local and F-Droid builds expect). + +inputs: + keystore-base64: + description: 'Base64-encoded JKS/PKCS12 keystore (secret RELEASE_KEYSTORE_BASE64 or KEYSTORE_BASE64)' + required: true + keystore-password: + description: 'Keystore password (secret KEYSTORE_PASSWORD)' + required: true + key-alias: + description: 'Key alias (secret KEY_ALIAS); detected from the keystore when empty' + required: false + default: '' + key-password: + description: 'Key password (secret KEY_PASSWORD)' + required: true + required: + description: 'Fail the job when no keystore is configured instead of building unsigned' + required: false + default: 'false' + +outputs: + signed: + description: "'true' when Gradle will sign the artifacts" + value: ${{ steps.prepare.outputs.signed }} + keystore-path: + description: 'Path of the temporary keystore file (empty when unsigned); delete it after the build' + value: ${{ steps.prepare.outputs.path }} + +runs: + using: composite + steps: + - id: prepare + shell: bash + env: + # Passed through env so the values never appear in a rendered command line. + KS_B64: ${{ inputs.keystore-base64 }} + KS_PASS: ${{ inputs.keystore-password }} + KS_ALIAS: ${{ inputs.key-alias }} + KS_KEY_PASS: ${{ inputs.key-password }} + KS_REQUIRED: ${{ inputs.required }} + run: | + set -euo pipefail + + if [ -z "$KS_B64" ]; then + echo "signed=false" >> "$GITHUB_OUTPUT" + echo "path=" >> "$GITHUB_OUTPUT" + if [ "$KS_REQUIRED" = "true" ]; then + echo "::error::RELEASE_KEYSTORE_BASE64 (or KEYSTORE_BASE64) is not set, and this job must publish signed artifacts." + exit 1 + fi + echo "::warning::No release keystore secret configured - artifacts will be UNSIGNED." + exit 0 + fi + + if [ -z "$KS_PASS" ] || [ -z "$KS_KEY_PASS" ]; then + echo "::error::KEYSTORE_PASSWORD and KEY_PASSWORD secrets are required to sign." + exit 1 + fi + + KS_PATH="${RUNNER_TEMP}/gothwad_release.keystore" + printf '%s' "$KS_B64" | base64 -d > "$KS_PATH" + chmod 600 "$KS_PATH" + + ALIAS="$KS_ALIAS" + if [ -z "$ALIAS" ]; then + ALIAS="$(keytool -list -v -keystore "$KS_PATH" -storepass "$KS_PASS" 2>/dev/null \ + | grep -i 'Alias name:' | head -n 1 | sed 's/.*Alias name: *//I' | tr -d '\r' | xargs || true)" + fi + if [ -z "$ALIAS" ]; then + echo "::error::Could not determine the key alias - set the KEY_ALIAS repository secret." + rm -f "$KS_PATH" + exit 1 + fi + + { + echo "KEYSTORE_FILE=${KS_PATH}" + echo "KEYSTORE_PASSWORD=${KS_PASS}" + echo "KEY_ALIAS=${ALIAS}" + echo "KEY_PASSWORD=${KS_KEY_PASS}" + } >> "$GITHUB_ENV" + + echo "signed=true" >> "$GITHUB_OUTPUT" + echo "path=${KS_PATH}" >> "$GITHUB_OUTPUT" + echo "Release signing configured for alias '${ALIAS}'." diff --git a/.github/actions/resolve-version/action.yml b/.github/actions/resolve-version/action.yml new file mode 100644 index 0000000..9dffd3f --- /dev/null +++ b/.github/actions/resolve-version/action.yml @@ -0,0 +1,68 @@ +name: Resolve version name and code +description: > + Turns a git tag or a manual workflow input into a version name and a version code. + The code is derived from the name (MAJOR*10000 + MINOR*100 + PATCH) so it always increases + with the version - the previous "10 + workflow run number" scheme was per-workflow, so two + workflows could publish the same code and a re-run could go backwards, which the Play Store + rejects. + +inputs: + version: + description: 'Explicit version name (empty = derive from the ref)' + required: false + default: '' + version-code: + description: 'Explicit version code (empty = derive from the version name)' + required: false + default: '' + ref-name: + description: 'Git ref name, e.g. v1.0.7' + required: true + fallback-base: + description: 'Base added to the run number when no numeric version can be derived' + required: false + default: '10000' + +outputs: + name: + description: 'Version name without the leading v, e.g. 1.0.7' + value: ${{ steps.resolve.outputs.name }} + code: + description: 'Monotonic integer version code, e.g. 10007' + value: ${{ steps.resolve.outputs.code }} + +runs: + using: composite + steps: + - id: resolve + shell: bash + run: | + set -euo pipefail + + RAW_NAME="${{ inputs.version }}" + if [ -z "$RAW_NAME" ]; then + case "${{ inputs.ref-name }}" in + v*) RAW_NAME="${{ inputs.ref-name }}" ;; + *) RAW_NAME="1.0.${GITHUB_RUN_NUMBER}" ;; + esac + fi + V_NAME="${RAW_NAME#v}" + + # 1.2.3 -> 10203 ; 1.0.7-beta -> 10007 ; anything unparseable -> 0 + DIGITS="$(printf '%s' "$V_NAME" | tr -cd '0-9.')" + MA="$(printf '%s' "$DIGITS" | cut -d. -f1)"; MA="${MA:-0}" + MI="$(printf '%s' "$DIGITS" | cut -d. -f2)"; MI="${MI:-0}" + PA="$(printf '%s' "$DIGITS" | cut -d. -f3)"; PA="${PA:-0}" + DERIVED=$(( 10#$MA * 10000 + 10#$MI * 100 + 10#$PA )) + + if [ -n "${{ inputs.version-code }}" ]; then + V_CODE="${{ inputs.version-code }}" + elif [ "$DERIVED" -gt 0 ]; then + V_CODE="$DERIVED" + else + V_CODE=$(( ${{ inputs.fallback-base }} + GITHUB_RUN_NUMBER )) + fi + + echo "name=$V_NAME" >> "$GITHUB_OUTPUT" + echo "code=$V_CODE" >> "$GITHUB_OUTPUT" + echo "Resolved version ${V_NAME} (versionCode ${V_CODE})" diff --git a/.github/workflows/build_apk.yml b/.github/workflows/build_apk.yml index a4f58a4..2e9bcf2 100644 --- a/.github/workflows/build_apk.yml +++ b/.github/workflows/build_apk.yml @@ -1,185 +1,124 @@ -name: Build Signed Release APK & Play Store AAB +name: Build Artifacts + +# Manual, on-demand build of installable artifacts. +# +# This workflow used to also run on every push to main/master *and* publish a GitHub Release, +# which duplicated release.yml: two workflows created the same tag from independent run-number +# based version codes, and every push to main published a public release. Pushes are now covered +# by ci.yml (verification only) and releases by release.yml (tags). This one is purely +# "build me an APK/AAB I can sideload", signed when the keystore secrets exist. on: - push: - branches: [ main, master ] workflow_dispatch: inputs: version_name: - description: 'Version Name (e.g. 1.0.6 - leave empty for auto 1.0.X)' + description: 'Version name (e.g. 1.0.7 — empty = derive from the run number)' required: false default: '' version_code: - description: 'Version Code (Integer, e.g. 12 - leave blank for auto increment)' + description: 'Version code (empty = MAJOR*10000 + MINOR*100 + PATCH)' required: false default: '' permissions: - contents: write + contents: read + +concurrency: + group: artifacts-${{ github.ref }} + cancel-in-progress: true jobs: build: - name: Build & Sign APK & AAB + name: Build signed APK, AAB and debug APK runs-on: ubuntu-latest steps: - - name: Checkout Source Code + - name: Checkout uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Set up Java 17 (JDK) + - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' + cache: 'gradle' - - name: Validate and Prepare Gradle Wrapper - run: | - if [ ! -f "gradle/wrapper/gradle-wrapper.jar" ]; then - echo "gradle-wrapper.jar not found, generating wrapper..." - gradle wrapper --gradle-version 8.10.2 || true - fi - chmod +x ./gradlew + - name: Make gradlew executable + run: chmod +x ./gradlew - - name: Determine Version Code and Name - id: versioning + - name: Create a debug keystore run: | - if [ -n "${{ inputs.version_code }}" ]; then - V_CODE="${{ inputs.version_code }}" - else - V_CODE=$(( 10 + ${{ github.run_number }} )) - fi - - if [ -n "${{ inputs.version_name }}" ]; then - V_NAME="${{ inputs.version_name }}" - else - # Auto-incrementing version name based on version code - V_NAME="1.0.${V_CODE}" + mkdir -p ~/.android + if [ ! -f ~/.android/debug.keystore ]; then + keytool -genkeypair -v -keystore ~/.android/debug.keystore \ + -alias androiddebugkey -storepass android -keypass android \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=Android Debug,O=Android,C=US" fi - echo "Determined Version Code: $V_CODE" - echo "Determined Version Name: $V_NAME" - echo "v_code=$V_CODE" >> $GITHUB_OUTPUT - echo "v_name=$V_NAME" >> $GITHUB_OUTPUT + - name: Resolve version + id: version + uses: ./.github/actions/resolve-version + with: + version: ${{ inputs.version_name }} + version-code: ${{ inputs.version_code }} + ref-name: ${{ github.ref_name }} + + # Not required here: without the secrets this still produces installable debug artifacts. + - name: Prepare release signing + id: signing + uses: ./.github/actions/prepare-signing + with: + keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 || secrets.KEYSTORE_BASE64 }} + keystore-password: ${{ secrets.KEYSTORE_PASSWORD }} + key-alias: ${{ secrets.KEY_ALIAS }} + key-password: ${{ secrets.KEY_PASSWORD }} + required: 'false' - - name: Build Release APK, Debug APK and AAB Bundle with Gradle + - name: Build run: | - mkdir -p ~/.android - if [ ! -f ~/.android/debug.keystore ]; then - keytool -genkey -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US" || true - fi - ./gradlew assembleRelease assembleDebug bundleRelease \ - -PversionCode=${{ steps.versioning.outputs.v_code }} \ - -PversionName=${{ steps.versioning.outputs.v_name }} \ - --no-daemon - - - name: Sign Release APK & AAB - env: - KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 || secrets.KEYSTORE_BASE64 }} - KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASSWORD }} - KEY_ALIAS: ${{ secrets.KEY_ALIAS }} - KEY_PASS: ${{ secrets.KEY_PASSWORD }} - V_NAME: ${{ steps.versioning.outputs.v_name }} + ./gradlew --no-daemon --stacktrace \ + assembleRelease bundleRelease assembleDebug \ + -PversionCode=${{ steps.version.outputs.code }} \ + -PversionName=${{ steps.version.outputs.name }} + + - name: Collect artifacts run: | - echo "Locating generated Release APK, Debug APK, and AAB..." - UNSIGNED_APK=$(find app/build/outputs/apk/release/ -name "*.apk" | head -n 1) - DEBUG_APK=$(find app/build/outputs/apk/debug/ -name "*.apk" | head -n 1) - UNSIGNED_AAB=$(find app/build/outputs/bundle/release/ -name "*.aab" | head -n 1) - - mkdir -p output_release - - # 0. Copy Debug APK directly (already signed with debug keystore) - if [ -n "$DEBUG_APK" ]; then - cp "$DEBUG_APK" output_release/GothwadLauncher-v${V_NAME}-Debug.apk - echo "Successfully included Debug APK: output_release/GothwadLauncher-v${V_NAME}-Debug.apk" - fi + set -euo pipefail + V_NAME="${{ steps.version.outputs.name }}" + mkdir -p build_out - if [ -z "$KEYSTORE_BASE64" ]; then - echo "❌ Error: RELEASE_KEYSTORE_BASE64 secret is missing in GitHub Repository Secrets!" - echo "Please add RELEASE_KEYSTORE_BASE64, KEYSTORE_PASSWORD, KEY_ALIAS, and KEY_PASSWORD to GitHub Secrets." - exit 1 - fi + APK="$(find app/build/outputs/apk/release -name '*.apk' | head -n 1 || true)" + AAB="$(find app/build/outputs/bundle/release -name '*.aab' | head -n 1 || true)" + DEBUG_APK="$(find app/build/outputs/apk/debug -name '*.apk' | head -n 1 || true)" - if [ -z "$KEYSTORE_PASS" ]; then - echo "❌ Error: KEYSTORE_PASSWORD secret is missing in GitHub Repository Secrets!" - exit 1 + if [ -n "$APK" ]; then + cp "$APK" "build_out/GothwadLauncher-v${V_NAME}-Release.apk" fi - - KS_PATH="/tmp/gothwad_release_key.jks" - echo "Decoding permanent release keystore from GitHub Secrets..." - echo "$KEYSTORE_BASE64" | base64 -d > "$KS_PATH" - - # Dynamically detect exact Alias name from the keystore - DETECTED_ALIAS=$(keytool -list -v -keystore "$KS_PATH" -storepass "$KEYSTORE_PASS" 2>/dev/null | grep -i "Alias name:" | head -n 1 | sed 's/.*Alias name: //I' | tr -d '\r' | xargs) - - if [ -n "$KEY_ALIAS" ]; then - ACTUAL_ALIAS="$KEY_ALIAS" - elif [ -n "$DETECTED_ALIAS" ]; then - ACTUAL_ALIAS="$DETECTED_ALIAS" - else - ACTUAL_ALIAS="gothwad_launcher_key" + if [ -n "$AAB" ]; then + cp "$AAB" "build_out/GothwadLauncher-v${V_NAME}-PlayStore.aab" fi - - echo "Using Keystore Alias: $ACTUAL_ALIAS" - - # Find latest zipalign and apksigner dynamically - ZIPALIGN=$(find $ANDROID_HOME/build-tools/ -name "zipalign" | sort -V | tail -n 1) - APKSIGNER=$(find $ANDROID_HOME/build-tools/ -name "apksigner" | sort -V | tail -n 1) - - # 1. Sign APK - if [ -n "$UNSIGNED_APK" ]; then - UNALIGNED_TEMP="/tmp/unaligned_temp.apk" - if [ -n "$ZIPALIGN" ]; then - $ZIPALIGN -v -p 4 "$UNSIGNED_APK" "$UNALIGNED_TEMP" || cp "$UNSIGNED_APK" "$UNALIGNED_TEMP" - else - cp "$UNSIGNED_APK" "$UNALIGNED_TEMP" - fi - - $APKSIGNER sign \ - --ks "$KS_PATH" \ - --ks-key-alias "$ACTUAL_ALIAS" \ - --ks-pass "pass:$KEYSTORE_PASS" \ - --key-pass "pass:$KEY_PASS" \ - --out output_release/GothwadLauncher-v${V_NAME}-Release.apk \ - "$UNALIGNED_TEMP" - - $APKSIGNER verify --verbose output_release/GothwadLauncher-v${V_NAME}-Release.apk - rm -f "$UNALIGNED_TEMP" + if [ -n "$DEBUG_APK" ]; then + cp "$DEBUG_APK" "build_out/GothwadLauncher-v${V_NAME}-Debug.apk" fi - - # 2. Sign Play Store AAB Bundle using jarsigner - if [ -n "$UNSIGNED_AAB" ]; then - cp "$UNSIGNED_AAB" output_release/GothwadLauncher-v${V_NAME}-PlayStore.aab - jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \ - -keystore "$KS_PATH" \ - -storepass "$KEYSTORE_PASS" -keypass "$KEY_PASS" \ - output_release/GothwadLauncher-v${V_NAME}-PlayStore.aab \ - "$ACTUAL_ALIAS" - echo "Successfully signed Play Store AAB!" + + if [ "${{ steps.signing.outputs.signed }}" != "true" ]; then + echo "::warning::Release artifacts are UNSIGNED (no keystore secret configured)." fi + ls -l build_out - # Securely destroy the temporary keystore file so it is never exposed or uploaded - rm -f "$KS_PATH" - echo "Keystore securely cleaned up." + - name: Destroy the temporary keystore + if: always() + run: | + KS="${{ steps.signing.outputs.keystore-path }}" + if [ -n "$KS" ] && [ -f "$KS" ]; then + rm -f "$KS" + fi - - name: Upload Signed Release APK, Debug APK & AAB Artifacts + - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: Gothwad-Launcher-v${{ steps.versioning.outputs.v_name }}-Build${{ steps.versioning.outputs.v_code }} - path: output_release/ - - - name: Create Automatic GitHub Release - uses: softprops/action-gh-release@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: v${{ steps.versioning.outputs.v_name }} - name: v${{ steps.versioning.outputs.v_name }} - draft: false - prerelease: false - generate_release_notes: true - files: | - output_release/GothwadLauncher-v${{ steps.versioning.outputs.v_name }}-Release.apk - output_release/GothwadLauncher-v${{ steps.versioning.outputs.v_name }}-Debug.apk - output_release/GothwadLauncher-v${{ steps.versioning.outputs.v_name }}-PlayStore.aab + name: Gothwad-Launcher-v${{ steps.version.outputs.name }}-build${{ steps.version.outputs.code }} + path: build_out/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7d885be --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: CI + +# Verification build: runs on every pull request and on every push to main. +# This is the workflow that answers "does it still compile, do the tests pass, does lint +# regress, and does the *release* (R8 + resource shrinking) build still succeed". +# Signed release publishing lives in release.yml (tags) and build_apk.yml (manual). + +on: + pull_request: + push: + branches: [ main, master ] + workflow_dispatch: + +permissions: + contents: read + +# A new push to the same PR replaces the in-flight run instead of queueing behind it. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Compile, test, lint + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Fast static checks that need no JDK and no Android SDK, so a truncated source + # file or a dangling resource reference fails in seconds instead of after the + # Gradle download. See tools/ and docs/I18N.md. + - name: Static checks (balance + resource references) + run: | + python3 tools/ktcheck.py app/src + python3 tools/check_resources.py app/src/main + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'gradle' + + - name: Make gradlew executable + run: chmod +x ./gradlew + + # app/build.gradle.kts signs debug builds with a custom "debugConfig" that points at + # ~/.android/debug.keystore. AGP only auto-generates the keystore for its *own* default + # debug config, so without this step every build fails with "Keystore file ... not found". + - name: Create a debug keystore + run: | + mkdir -p ~/.android + if [ ! -f ~/.android/debug.keystore ]; then + keytool -genkeypair -v -keystore ~/.android/debug.keystore \ + -alias androiddebugkey -storepass android -keypass android \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=Android Debug,O=Android,C=US" + fi + + # assembleRelease is part of verification on purpose: release has + # isMinifyEnabled + isShrinkResources on, and R8 is where missing keep rules and + # stripped resources show up. Without KEYSTORE_FILE the output stays unsigned, + # which is exactly what we want to compile-check here. + - name: Build debug + release, run unit tests + run: | + ./gradlew --no-daemon --stacktrace \ + assembleDebug \ + assembleRelease \ + testDebugUnitTest + + - name: Android Lint + run: ./gradlew --no-daemon lintDebug + + - name: Upload debug APK + if: always() + uses: actions/upload-artifact@v4 + with: + name: GothwadLauncher-debug-${{ github.run_number }} + path: app/build/outputs/apk/debug/*.apk + if-no-files-found: ignore + retention-days: 14 + + - name: Upload test + lint reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: reports-${{ github.run_number }} + path: | + app/build/reports/lint-results-debug.html + app/build/reports/tests/ + if-no-files-found: ignore + retention-days: 14 + + - name: Summarise failures + if: failure() + run: | + echo "### Build failed" >> "$GITHUB_STEP_SUMMARY" + echo 'See the `reports-*` artifact for the lint and unit-test output.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c999b1..4b72991 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,8 @@ -name: Release to GitHub Releases +name: Release + +# Publishes signed artifacts to a GitHub Release. +# Triggered by a version tag (v*) or manually. Compilation is verified on every PR by ci.yml; +# this workflow is only about signing and publishing. on: push: @@ -7,175 +11,147 @@ on: workflow_dispatch: inputs: version: - description: 'Version Name (e.g. 1.0.6 - leave empty for auto 1.0.X)' + description: 'Version name (e.g. 1.0.7 — empty = derive from the tag / run number)' required: false default: '' version_code: - description: 'Version Code (Integer, e.g. 12 - leave blank for auto increment)' + description: 'Version code (empty = MAJOR*10000 + MINOR*100 + PATCH)' required: false default: '' permissions: contents: write +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: - build_release: - name: Build & Publish Release + release: + name: Build, sign and publish runs-on: ubuntu-latest steps: - - name: Checkout Source Code + - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Set up Java 17 + - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' + cache: 'gradle' - - name: Validate and Prepare Gradle Wrapper - run: | - if [ ! -f "gradle/wrapper/gradle-wrapper.jar" ]; then - echo "gradle-wrapper.jar not found, generating wrapper..." - gradle wrapper --gradle-version 8.10.2 || true - fi - chmod +x ./gradlew + - name: Make gradlew executable + run: chmod +x ./gradlew - - name: Determine Version Code and Name - id: versioning - run: | - if [ -n "${{ inputs.version_code }}" ]; then - V_CODE="${{ inputs.version_code }}" - else - V_CODE=$(( 10 + ${{ github.run_number }} )) - fi - - if [ -n "${{ inputs.version }}" ]; then - V_NAME="${{ inputs.version }}" - elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then - V_NAME="${GITHUB_REF_NAME#v}" - else - V_NAME="1.0.${V_CODE}" - fi - - echo "Determined Version Code: $V_CODE" - echo "Determined Version Name: $V_NAME" - echo "v_code=$V_CODE" >> $GITHUB_OUTPUT - echo "v_name=$V_NAME" >> $GITHUB_OUTPUT - - - name: Build Release APK, Debug APK and AAB + # The debug build type uses a custom signing config that points at + # ~/.android/debug.keystore, which AGP does not create for custom configs. + - name: Create a debug keystore run: | mkdir -p ~/.android if [ ! -f ~/.android/debug.keystore ]; then - keytool -genkey -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US" || true + keytool -genkeypair -v -keystore ~/.android/debug.keystore \ + -alias androiddebugkey -storepass android -keypass android \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=Android Debug,O=Android,C=US" fi - ./gradlew assembleRelease assembleDebug bundleRelease \ - -PversionCode=${{ steps.versioning.outputs.v_code }} \ - -PversionName=${{ steps.versioning.outputs.v_name }} \ - --no-daemon - - name: Sign Release APK & AAB - env: - KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 || secrets.KEYSTORE_BASE64 }} - KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASSWORD }} - KEY_ALIAS: ${{ secrets.KEY_ALIAS }} - KEY_PASS: ${{ secrets.KEY_PASSWORD }} - V_NAME: ${{ steps.versioning.outputs.v_name }} + - name: Resolve version + id: version + uses: ./.github/actions/resolve-version + with: + version: ${{ inputs.version }} + version-code: ${{ inputs.version_code }} + ref-name: ${{ github.ref_name }} + + # Exports KEYSTORE_FILE / KEYSTORE_PASSWORD / KEY_ALIAS / KEY_PASSWORD so the Android + # Gradle Plugin signs the APK and the AAB itself (v1+v2+v3). No apksigner/jarsigner step. + - name: Prepare release signing + id: signing + uses: ./.github/actions/prepare-signing + with: + keystore-base64: ${{ secrets.RELEASE_KEYSTORE_BASE64 || secrets.KEYSTORE_BASE64 }} + keystore-password: ${{ secrets.KEYSTORE_PASSWORD }} + key-alias: ${{ secrets.KEY_ALIAS }} + key-password: ${{ secrets.KEY_PASSWORD }} + required: 'true' + + - name: Build release APK, AAB and debug APK run: | - mkdir -p release_out - APK=$(find app/build/outputs/apk/release/ -name "*.apk" | head -n 1) - DEBUG_APK=$(find app/build/outputs/apk/debug/ -name "*.apk" | head -n 1) - AAB=$(find app/build/outputs/bundle/release/ -name "*.aab" | head -n 1) + ./gradlew --no-daemon --stacktrace \ + assembleRelease bundleRelease assembleDebug \ + -PversionCode=${{ steps.version.outputs.code }} \ + -PversionName=${{ steps.version.outputs.name }} - if [ -n "$DEBUG_APK" ]; then - cp "$DEBUG_APK" release_out/GothwadLauncher-v${V_NAME}-Debug.apk - echo "Copied Debug APK: release_out/GothwadLauncher-v${V_NAME}-Debug.apk" - fi + - name: Collect artifacts + run: | + set -euo pipefail + V_NAME="${{ steps.version.outputs.name }}" + mkdir -p release_out artifacts_out - if [ -z "$KEYSTORE_BASE64" ]; then - echo "❌ Error: RELEASE_KEYSTORE_BASE64 secret is missing in GitHub Repository Secrets!" - echo "Please add RELEASE_KEYSTORE_BASE64, KEYSTORE_PASSWORD, KEY_ALIAS, and KEY_PASSWORD to GitHub Secrets." - exit 1 - fi + APK="$(find app/build/outputs/apk/release -name '*.apk' | head -n 1 || true)" + AAB="$(find app/build/outputs/bundle/release -name '*.aab' | head -n 1 || true)" + DEBUG_APK="$(find app/build/outputs/apk/debug -name '*.apk' | head -n 1 || true)" - if [ -z "$KEYSTORE_PASS" ]; then - echo "❌ Error: KEYSTORE_PASSWORD secret is missing in GitHub Repository Secrets!" + if [ -n "$APK" ]; then + cp "$APK" "release_out/GothwadLauncher-v${V_NAME}-Release.apk" + else + echo "::error::No release APK was produced." exit 1 fi - - KS_PATH="/tmp/release_key.jks" - echo "Decoding permanent release keystore from GitHub Secrets..." - echo "$KEYSTORE_BASE64" | base64 -d > "$KS_PATH" - - # Dynamically detect exact Alias name from the keystore - DETECTED_ALIAS=$(keytool -list -v -keystore "$KS_PATH" -storepass "$KEYSTORE_PASS" 2>/dev/null | grep -i "Alias name:" | head -n 1 | sed 's/.*Alias name: //I' | tr -d '\r' | xargs) - - if [ -n "$KEY_ALIAS" ]; then - ACTUAL_ALIAS="$KEY_ALIAS" - elif [ -n "$DETECTED_ALIAS" ]; then - ACTUAL_ALIAS="$DETECTED_ALIAS" + if [ -n "$AAB" ]; then + cp "$AAB" "release_out/GothwadLauncher-v${V_NAME}-PlayStore.aab" else - ACTUAL_ALIAS="gothwad_launcher_key" + echo "::error::No release AAB was produced." + exit 1 fi - - echo "Using Keystore Alias: $ACTUAL_ALIAS" - - ZIPALIGN=$(find $ANDROID_HOME/build-tools/ -name "zipalign" | sort -V | tail -n 1) - APKSIGNER=$(find $ANDROID_HOME/build-tools/ -name "apksigner" | sort -V | tail -n 1) - - if [ -n "$APK" ]; then - UNALIGNED_TEMP="/tmp/unaligned_temp.apk" - if [ -n "$ZIPALIGN" ]; then - $ZIPALIGN -v -p 4 "$APK" "$UNALIGNED_TEMP" || cp "$APK" "$UNALIGNED_TEMP" - else - cp "$APK" "$UNALIGNED_TEMP" - fi - - $APKSIGNER sign \ - --ks "$KS_PATH" \ - --ks-key-alias "$ACTUAL_ALIAS" \ - --ks-pass "pass:$KEYSTORE_PASS" \ - --key-pass "pass:$KEY_PASS" \ - --out release_out/GothwadLauncher-v${V_NAME}-Release.apk \ - "$UNALIGNED_TEMP" - - $APKSIGNER verify --verbose release_out/GothwadLauncher-v${V_NAME}-Release.apk - rm -f "$UNALIGNED_TEMP" + # The debug APK is a workflow artifact only, NOT part of the public release: it is + # signed with a different key, so a user who installs it cannot upgrade to the signed + # release without uninstalling first. + if [ -n "$DEBUG_APK" ]; then + cp "$DEBUG_APK" "artifacts_out/GothwadLauncher-v${V_NAME}-Debug.apk" fi + ls -l release_out artifacts_out - if [ -n "$AAB" ]; then - cp "$AAB" release_out/GothwadLauncher-v${V_NAME}-PlayStore.aab - jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \ - -keystore "$KS_PATH" \ - -storepass "$KEYSTORE_PASS" -keypass "$KEY_PASS" \ - release_out/GothwadLauncher-v${V_NAME}-PlayStore.aab \ - "$ACTUAL_ALIAS" - echo "Successfully signed Play Store AAB!" + - name: Verify APK signature + run: | + set -euo pipefail + V_NAME="${{ steps.version.outputs.name }}" + APKSIGNER="$(find "$ANDROID_HOME/build-tools" -name apksigner | sort -V | tail -n 1)" + "$APKSIGNER" verify --verbose --print-certs \ + "release_out/GothwadLauncher-v${V_NAME}-Release.apk" + + # The temporary keystore must not survive the job, whatever happened above. + - name: Destroy the temporary keystore + if: always() + run: | + KS="${{ steps.signing.outputs.keystore-path }}" + if [ -n "$KS" ] && [ -f "$KS" ]; then + rm -f "$KS" + echo "Temporary keystore removed." fi - # Securely destroy the temporary keystore file so it is never exposed or uploaded - rm -f "$KS_PATH" - echo "Keystore securely cleaned up." - - - name: Upload Release Artifacts + - name: Upload workflow artifacts uses: actions/upload-artifact@v4 with: - name: Gothwad-Launcher-v${{ steps.versioning.outputs.v_name }}-Release - path: release_out/ + name: Gothwad-Launcher-v${{ steps.version.outputs.name }}-debug + path: artifacts_out/ + if-no-files-found: ignore - - name: Create GitHub Release + - name: Publish GitHub Release uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - tag_name: v${{ steps.versioning.outputs.v_name }} - name: v${{ steps.versioning.outputs.v_name }} + tag_name: v${{ steps.version.outputs.name }} + name: v${{ steps.version.outputs.name }} draft: false prerelease: false generate_release_notes: true + fail_on_unmatched_files: true files: | - release_out/GothwadLauncher-v${{ steps.versioning.outputs.v_name }}-Release.apk - release_out/GothwadLauncher-v${{ steps.versioning.outputs.v_name }}-Debug.apk - release_out/GothwadLauncher-v${{ steps.versioning.outputs.v_name }}-PlayStore.aab + release_out/GothwadLauncher-v${{ steps.version.outputs.name }}-Release.apk + release_out/GothwadLauncher-v${{ steps.version.outputs.name }}-PlayStore.aab diff --git a/AGENTS.md b/AGENTS.md index 37ad1ab..248b6bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # Gothwad Launcher Developer & Architecture Rules +Short, non-negotiable rules. The reasoning and the detail live in [`docs/`](docs/README.md). + ## Architecture: Native Android Views Only (No Jetpack Compose) This project has been **fully migrated from Jetpack Compose to 100% native Android Views** (XML layouts + Fragments/Activities + ViewBinding + RecyclerView + DialogFragment/BottomSheetDialogFragment). @@ -16,3 +18,93 @@ This project has been **fully migrated from Jetpack Compose to 100% native Andro - Use `RecyclerView` with optimized `DiffUtil.ItemCallback` and `RecyclerView.RecycledViewPool` for app grids and lists. - Use `SmoothCornerDrawable` (backed by `androidx.graphics.shapes`) and `AppIcons` for high-performance vector rendering. - State management uses Kotlin Coroutines, `StateFlow`, and lifecycle-aware coroutines (`repeatOnLifecycle`, `lifecycleScope`). + +--- + +## The other rules that have each cost a real bug + +### 1. One process owns the DataStore +`ConfigStore` is read and written **only from the main process**. The `:watchdog` process is a +separate process and must never touch it — Preferences DataStore is single-process and two writers +can corrupt the file. Cross-process state goes through `ProcessHeartbeat`'s small files. + +### 2. Boot-critical state lives in device-protected storage +Anything read before first unlock (`gothwad_heartbeat`, `gothwad_relaunch_policy`, +`gothwad_selfheal`) must come from `createDeviceProtectedStorageContext().filesDir` via +`ProcessHeartbeat.sharedDir()`. Reading credential-encrypted `filesDir` there throws, the throw +gets swallowed, and the watchdog concludes the launcher died and hijacks the screen (issue #26). + +### 3. No blocking work on the main thread +No `PackageManager` queries, no bitmap decode, no `Runtime.exec`. Specifically: +- `AppRepository.scan()` pins itself to `Dispatchers.IO` — do not remove that or call PM APIs + around it on Main. +- The root/shell probe (`su -c id`) can block for as long as a root prompt waits for the user; it + runs behind `AppUpdateManager.detectInstallCapability()` on IO, and the Settings badge resolves + asynchronously with a "Checking…" state. +- `BootReceiver` starts a service and returns; never run the boot-shield loop inside `goAsync()`. + +### 4. Fail secure, never fail open +If a lock overlay cannot be attached, or the config has not loaded yet, the user waits or is sent +HOME — a locked app is never revealed. `AppLockGate` (UX), `LauncherAccessibilityService` +(enforcement) and `LockSecurity` (credentials) each own their decision exactly once; do not add a +fourth copy of the rule (issue #31). + +### 5. Never invent device state +Bluetooth unavailable means report **unknown**, not a placeholder remote name and battery level. +`ActivityManager.getRunningAppProcesses()` returning null means **unknown**, not "the process is +dead" (issue #27). + +### 6. Destructive behaviour is opt-in +Killing other apps to reclaim memory (`aggressiveMemoryTrim`) defaults to **false** and must stay +that way (issue #34). A launcher must not murder the user's music for a few MB of headroom. + +### 7. No `BuildConfig` +`buildFeatures` enables ViewBinding only. Read the app version from +`packageManager.getPackageInfo`, not from a generated constant. + +### 8. minSdk is 21 +Guard newer APIs at the call site (`SDK_INT >= M` for `PendingIntent.FLAG_IMMUTABLE`, notification +channels, `setExactAndAllowWhileIdle` permissions, …). + +### 9. User-visible text is a resource +`R.string.*` / `@string/*`, never a literal, never string concatenation — use format strings. +There is an existing backlog of ~260 literals: do not add to it. Status and inventory: +[docs/I18N.md](docs/I18N.md). + +### 10. Density and UI scale go through `DensityAdapter` only +Density is derived from a 960 dp design width, **not** from the firmware's `densityDpi`. The +`uiScale` and `respectSystemFontScale` mirror in the `gothwad_density` SharedPreferences is +intentional (it makes the first frame after a cold start correct); `LauncherConfig` remains the +source of truth and `MainStatusBarController.applyDensityPolicy()` pushes it in. Keep the neutral +default at `uiScale = 2` (1.0×) — index 2 *is* 1.0× in `UI_SCALES`. + +### 11. Signing and versioning are Gradle's job +No `jarsigner`/`apksigner sign` steps in workflows (AGP produces v1+v2+v3 for both APK and AAB). +Signing config is read with `providers.environmentVariable(...)` — **not** `System.getenv` — because +the configuration cache is enabled. `versionCode` is derived from the version name +(`MAJOR*10000 + MINOR*100 + PATCH`) so it stays monotonic. Any workflow that runs `assembleDebug` +must create `~/.android/debug.keystore` first (the custom `debugConfig` is not auto-generated). +Details: [docs/CI_RELEASE.md](docs/CI_RELEASE.md). + +### 12. Keep minification working +`isMinifyEnabled` + `isShrinkResources` are on for release. kotlinx.serialization needs the keep +rules in `app/proguard-rules.pro`; if you add another reflection-based API, add its rules and build +`assembleRelease` locally. `dependenciesInfo` stays disabled for F-Droid reproducibility. + +### 13. `metadata.json` is platform metadata +It belongs to the tooling that hosts this project, not to the app. Do not delete it, and do not +treat its contents as app configuration. + +--- + +## Before you push + +```bash +./gradlew assembleDebug testDebugUnitTest lintDebug +./gradlew assembleRelease # minification is a different failure mode from compiling +``` + +Then update whatever your change invalidates: `CHANGELOG.md` (under **Unreleased**), +`docs/CONFIG.md` for any config field, `docs/SECURITY.md` for anything touching locks or +credentials, and this file if you are adding a rule. See +[CONTRIBUTING.md](CONTRIBUTING.md) for the full checklist. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..298d2e3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,106 @@ +# Changelog + +All notable changes to Gothwad Launcher. Format loosely follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver, and the +`versionCode` is derived from it (`MAJOR*10000 + MINOR*100 + PATCH`). + +Unreleased changes accumulate under **Unreleased** and are moved into a version entry when the tag +is cut. + +## [Unreleased] + +### Fixed — correctness & stability + +- **UI Scale now actually scales the UI.** The setting was stored and read back but never applied + to anything; `DensityAdapter` now derives density from a 960 dp design width times the chosen + scale, and the activity recreates when the effective scale changes. The default moved from index + 3 to index 2 so 1.0× stays neutral. +- **App scanning no longer runs on the main thread.** `AppRepository.scan()` pins itself to + `Dispatchers.IO`, so a caller cannot accidentally do two `queryIntentActivities` plus N + `getPackageInfo` round-trips on the UI thread. +- **Two concurrency bugs in the app-art cache:** `validCacheNames` was a plain `HashSet` mutated + from concurrent `async(IO)` blocks (now synchronised), and `memoryCache!!` could NPE / tear under + `clearMemoryCache()` (now `@Volatile`, returning the same list instance). +- **Notification dot was invisible** (drawn with zero alpha). +- **Grid could never become empty:** `rebuildAppList()` returned early when the new list was empty. +- **App lock failed open on cold start.** Before the first DataStore emission the effective config + was an empty default in which nothing is locked; the foreground package is now parked and + re-checked once the real config arrives. +- **Watchdog no longer hijacks the screen before first unlock.** Shared state moved to + device-protected storage, and relaunching HOME honours a persisted + `launchOnBoot || isDefaultHomeApp` policy instead of assuming. +- **Watchdog / boot-shield relaunch worked on Android 10+**, where background-activity-launch rules + were dropping the old intent. Both now use a shared `homeIntent()` + `launchHomeCompat`. +- **`getRunningAppProcesses()` is no longer treated as authoritative** — it returns null on many + builds, which the boot shield read as "the launcher died". +- **Opening Settings no longer ANRs.** The root/ADB probe (`su -c id`) ran on the main thread and + can block for as long as a root prompt waits for the user; the capability badge now resolves on + IO and shows a "Checking…" state. +- **Corrupt config JSON is logged** before falling back to defaults, instead of resetting the + launcher silently. +- **`BootReceiver` no longer runs the guard loop inside `goAsync()`** (ANR on every boot); it + starts the service and returns. + +### Fixed — security & privacy + +- **The hidden-apps reveal code is no longer stored in plain text.** It now uses the same salted + PBKDF2 primitive as the PINs, is compared in constant time, and is not echoed back into the + settings field (the dialog says the current code cannot be shown). Existing plain-text codes are + hashed and cleared on first launch after upgrade. +- **`BLUETOOTH_CONNECT` is actually requested** on Android 12+ when the Quick Dashboard opens. + When denied, the dashboard reports "unknown" instead of a placeholder remote name and battery. +- **Self-update download hardened:** redirects are followed manually and only to `http(s)` targets + (the result feeds a package installer), non-2xx responses fail the download, connections are + disconnected in `finally`, and progress is dispatched on the main thread only when the integer + percentage changes (it used to dispatch per 8 KB chunk). +- **`installViaAdbShell` uses bounded subprocesses** (8 s) and reports success only on exit code 0 + with a "Success" response, accumulating the last error otherwise. +- **Aggressive memory trimming is now opt-in and off by default.** It used to kill other apps — + including whatever was playing — on every launch. + +### Removed + +- Dead configuration fields: `categories`, `sections`, `knownApps`, `autoCategoryOnInstall`, + `iconScale`, `showCategoryNames`, `showAppLabels`, `layout`, `vpnApp`, `showVpnButton`. Old + 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. +- `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` + (Gradle signs both artifacts itself; only `apksigner verify --print-certs` remains). +- The duplicate GitHub Release step in `build_apk.yml`, and its push-to-`main` trigger — two + workflows were publishing releases with colliding `v1.0.` tags. + +### Changed + +- 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. +- ~29 hard-coded English strings moved to resources (locks, accessibility alert, search, Quick + Dashboard, settings titles and subtitles, version text, self-update capability badge). 260 + literals remain — tracked in [docs/I18N.md](docs/I18N.md). +- `Config.uiScale` documented as an index (0..4), not a multiplier. +- Signing env vars are read through `providers.environmentVariable` so the configuration cache + tracks them; `-PlocalSign` now uses the project's `debugConfig` rather than AGP's built-in + `debug`. +- Unit tests return default values for unmocked framework calls + (`unitTests.isReturnDefaultValues = true`). + +### Added + +- **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). +- 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. + +## [1.0.6] + +- Status bar centre cluster (clock/date between the left and right groups). +- Previous release as published; no changelog was kept before this file existed. + +[Unreleased]: https://github.com/GothwadTech/launcher/compare/main...HEAD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f515e0e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,115 @@ +# Contributing + +Thanks — this project runs on a TV box with 2 GB of RAM and firmware that lies about its own +density, so contributions that respect those constraints are especially welcome. + +## Before you write code + +1. Read [AGENTS.md](AGENTS.md). It is short and its first rule is absolute: **no Jetpack Compose**, + not even a `ComposeView` bridge. This is a measured memory decision, not a taste preference. +2. Skim [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the layering and the process model, and + [docs/PERFORMANCE.md](docs/PERFORMANCE.md) for the rules that keep it fast on a slow box. +3. Check [docs/ROADMAP.md](docs/ROADMAP.md) — your idea may already be scoped, with the reason it + has not been done yet. + +## Ground rules + +- **Native Views only.** XML layouts, `Activity`/`Fragment`, ViewBinding, `RecyclerView`, + `DialogFragment`/`BottomSheetDialogFragment`. Coroutines + `StateFlow` + `repeatOnLifecycle`. + `SmoothCornerDrawable` and `AppIcons`/`VectorPathDrawable` for shapes and vectors. +- **No blocking work on the main thread.** No `PackageManager` queries, no bitmap decode, no + `Runtime.exec`. `su -c id` on the main thread was a real ANR. +- **Only the main process touches `ConfigStore`.** The `:watchdog` process communicates through + small files in device-protected storage (`ProcessHeartbeat`). Two processes on one Preferences + DataStore file can corrupt it. +- **Fail secure.** If a lock overlay cannot attach, or config has not loaded yet, the user waits or + goes HOME — a locked app is never revealed. +- **Never invent device state.** Bluetooth off means "unknown", not a made-up remote name and + battery percentage. `getRunningAppProcesses() == null` means "unknown", not "dead". +- **Destructive behaviour is opt-in.** Anything that kills other apps or reboots defaults to off. +- **minSdk is 21.** Guard newer APIs (`SDK_INT >= M` for `PendingIntent.FLAG_IMMUTABLE`, etc.). +- **No `BuildConfig`.** `buildFeatures` enables ViewBinding only; read the version from + `packageManager.getPackageInfo`. +- **No new dependencies without a memory justification.** `dependenciesInfo` is disabled for + F-Droid reproducibility; keep it that way. + +## Making a change + +1. Fork, branch from `main`, keep commits focused. +2. Write the code. If you add a config field, follow the checklist in + [docs/CONFIG.md](docs/CONFIG.md#adding-a-field--checklist) and update that table. +3. Add tests where the logic is pure (`data/` classes, no Android imports). See + [docs/TESTING.md](docs/TESTING.md). +4. Update the docs that your change invalidates — especially `docs/CONFIG.md`, + `docs/SECURITY.md` and `CHANGELOG.md` (add your entry under **Unreleased**). +5. Extract user-visible strings into `values/strings.xml`. See [docs/I18N.md](docs/I18N.md) — + there is a backlog, but do not add to it. +6. Verify locally: + +```bash +./gradlew assembleDebug testDebugUnitTest lintDebug +./gradlew assembleRelease # R8/minification is a different failure mode from compiling +``` + + No Android SDK? The repository ships two static checks that need only Python, and CI runs them + before Gradle: + +```bash +python3 tools/ktcheck.py app/src # every .kt file parses and is brace-balanced +python3 tools/check_resources.py app/src/main # every R.* / @type/name reference resolves +``` + + They catch the two failure modes that hurt most without a compiler: a truncated source file, + and a resource id that does not exist (a first-frame crash on a launcher). + +7. Open a pull request against `main`. CI builds debug + release, runs the tests and lint, and + attaches the debug APK — install it on a real box before asking for review if the change is + visual. + +## Pull request checklist + +- [ ] No Compose, no `androidx.compose.*` import, no `@Composable`. +- [ ] No blocking work added to the main thread. +- [ ] New/changed config fields documented in `docs/CONFIG.md`. +- [ ] `CHANGELOG.md` updated under **Unreleased**. +- [ ] User-visible strings are resources. +- [ ] Tests added for pure logic, or an explicit note on what was verified by hand and on which + device/firmware. +- [ ] `assembleRelease` builds (minification-safe). + +The PR template repeats this; fill it in rather than deleting it. + +## Reporting a bug + +Use the [bug report template](.github/ISSUE_TEMPLATE/bug_report.yml). For a TV launcher the device +details are not optional boilerplate: **firmware behaviour varies more than phone behaviour does**. +Always include the box model, Android version, and how the launcher was installed (GitHub Release +APK / Play / F-Droid / self-built debug). + +If the launcher crashes, attach the log: + +```bash +adb shell run-as com.gothwad.launcher cat files/gothwad_crash.log +adb logcat -d | grep -iE "gothwad|AndroidRuntime" > logcat.txt +``` + +`run-as` requires a debuggable build. + +## Security reports + +Do not open a public issue for anything that lets someone bypass a lock or read a credential. Email +the maintainers listed in the repository profile instead, and allow time for a fix before +disclosure. Threat model and current guarantees: [docs/SECURITY.md](docs/SECURITY.md). + +## Code style + +Kotlin official style, 4 spaces, no wildcard imports, trailing commas in multi-line literals. +Comments explain **why** — a comment that restates the code is noise, a comment that records the +firmware bug the code works around is a gift to the next reader. Prefer `when` over if-chains, +`runCatching` over try/catch where the failure is expected, and `Log.e(TAG, msg, e)` with the +throwable attached. + +## License + +By contributing you agree your contributions are licensed under the project's license (see +[LICENSE](LICENSE)). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c4c8d18 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Gothwad Tech + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 1f3f38f..380f6b2 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ ### 🎬 Wallpapers - Five built-in gradient presets (Midnight, Aurora, Sunset, Deep, Charcoal) plus custom wallpapers picked through the Storage Access Framework. -- Three scrim modes (top, bottom, both) so text and tiles stay readable over bright images. +- Five scrim modes (full, top only, bottom only, both edges, none) so text and tiles stay + readable over bright images. - Background-media awareness: when another app is playing audio, the launcher shows what is playing and can silence stock boot-ad audio while the boot shield is active. @@ -38,8 +39,11 @@ - Voice search modal with direct voice recognition support (`RecognizerIntent`). ### 🌤️ Quick Dashboard +- Opened by selecting the **clock cluster** in the status bar. - One-panel overview: Wi-Fi/network status, Bluetooth remote state and battery (when the remote reports it), volume/mute control and shortcuts into the relevant system settings. +- Bluetooth details are requested at the moment you look at them (`BLUETOOTH_CONNECT` on + Android 12+); if you decline, the panel says *unknown* rather than inventing a value. - No location access, no weather service — the launcher requests neither. ### 🔒 Privacy & App Security @@ -50,6 +54,14 @@ from another launcher, the recents switcher or notifications while locked. - `allowBackup=false`: config and credentials never leave the device through backups. +### 📐 Display & scaling +- Density is derived from a 960 dp design width instead of trusting the firmware's reported + DPI, which on TV boxes is often nonsense. +- **UI Scale** (0.75×–1.3×) and corner radius are user settings; the system font scale is + honoured (clamped 0.85–1.30) or pinned to 1.0×, your choice. +- The scale is mirrored into a small SharedPreferences so the first frame after a cold start + is already correct — the DataStore config arrives too late for that. + ### ⚡ Performance & Optimization - Crash/ANR self-healing: a file-backed heartbeat plus a `:watchdog` process, with a **safe mode** that stops the relaunch loop if the launcher keeps crashing. @@ -78,7 +90,7 @@ - **Performance:** AndroidX ProfileInstaller with a starter baseline profile (regeneration guide: `docs/baseline-profile.md`) - **Build:** AGP 8.5.2 + Gradle 8.10.2 (wrapper pinned), JDK 17, R8 full mode -- **Tests:** JVM unit tests for the grid rules, config JSON round-trip and credential hashing +- **Tests:** JVM unit tests for config JSON decoding and credential hashing/verification (`./gradlew :app:testDebugUnitTest`) --- @@ -114,14 +126,44 @@ ## 🤖 CI / CD (GitHub Actions) -This repository includes automated workflows in `.github/workflows/`: -- **`build_apk.yml`**: builds Debug + Release APKs and the Play Store AAB on every push to - `main` and on manual dispatch. It uploads them as **build artifacts only** — it does not - create tags or releases, so it can never collide with a release build. -- **`release.yml`**: triggered by a `v*` **tag**. This is the only workflow that publishes a - GitHub Release (with the APKs/AAB attached). -- Signing secrets are optional for ordinary builds: when they are missing the workflow - skips the export step instead of failing the run (forks/PRs stay green). +Three workflows in `.github/workflows/`, with the shared logic in two composite actions +(`resolve-version`, `prepare-signing`) so they cannot drift apart: + +| Workflow | Trigger | Does | Publishes | +| --- | --- | --- | --- | +| `ci.yml` | every **pull request**, push to `main`, manual | `assembleDebug` + `assembleRelease` (minified) + unit tests + lint | Debug APK and reports as run artifacts | +| `release.yml` | tag `v*`, manual | build, sign, verify | **GitHub Release** — signed Release APK + Play Store AAB | +| `build_apk.yml` | manual only | build whatever you ask for | Artifacts only, never a release | + +- `versionCode` is derived from the version name (`MAJOR*10000 + MINOR*100 + PATCH`), so it is + monotonic and two workflows cannot collide on a tag. +- Gradle signs both artifacts (v1+v2+v3). Signing secrets are **required** for `release.yml` — + a missing keystore fails the job instead of publishing an unsigned "release"; they stay + optional for `build_apk.yml` so forks and PRs stay green. +- The debug APK is attached to the *run*, not to the release: it is signed with a different key, + so installing it would block later upgrades. + +Full details — secrets, local signing, F-Droid reproducibility, cutting a release: +[docs/CI_RELEASE.md](docs/CI_RELEASE.md). + +--- + +## 📚 Documentation + +| | | +| --- | --- | +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Layers, process model, data flow, startup paths, invariants | +| [docs/CONFIG.md](docs/CONFIG.md) | Every persisted setting: type, default, reader, writer | +| [docs/SECURITY.md](docs/SECURITY.md) | Threat model, credential storage, lock enforcement, permissions | +| [docs/PERFORMANCE.md](docs/PERFORMANCE.md) | Memory/startup budget, caching, the rules that keep a 2 GB box usable | +| [docs/CI_RELEASE.md](docs/CI_RELEASE.md) | Workflows, secrets, signing, version policy | +| [docs/TESTING.md](docs/TESTING.md) | What is covered, how to run it, what to add | +| [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Device/OEM symptoms → cause → fix | +| [docs/baseline-profile.md](docs/baseline-profile.md) | The shipped profile and how to regenerate it with Macrobenchmark | +| [docs/I18N.md](docs/I18N.md) | String-extraction status and remaining inventory | +| [docs/ISSUE_LOG.md](docs/ISSUE_LOG.md) | The `#NN` numbers referenced in code comments | +| [docs/ROADMAP.md](docs/ROADMAP.md) | Known remaining work and explicit non-goals | +| [CONTRIBUTING.md](CONTRIBUTING.md) · [AGENTS.md](AGENTS.md) | Ground rules, PR checklist, code style | --- diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8584a00..78225af 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,7 +19,12 @@ android { // Release signing key, supplied by CI via environment variables (from GitHub // secrets). Absent locally and on F-Droid, so this config stays inert there. - val ciKeystore = System.getenv("KEYSTORE_FILE") + // + // Read through `providers.environmentVariable` rather than `System.getenv`: gradle.properties + // turns the configuration cache on, and only the provider API registers the variable as a + // configuration input. With System.getenv a cached configuration could keep signing with a + // stale (or absent) keystore after the environment changed. + val ciKeystore = providers.environmentVariable("KEYSTORE_FILE").orNull signingConfigs { create("debugConfig") { val rootKey = file("${rootDir}/debug.keystore") @@ -32,9 +37,9 @@ android { if (ciKeystore != null) { create("ci") { storeFile = file(ciKeystore) - storePassword = System.getenv("KEYSTORE_PASSWORD") - keyAlias = System.getenv("KEY_ALIAS") - keyPassword = System.getenv("KEY_PASSWORD") + storePassword = providers.environmentVariable("KEYSTORE_PASSWORD").orNull + keyAlias = providers.environmentVariable("KEY_ALIAS").orNull + keyPassword = providers.environmentVariable("KEY_PASSWORD").orNull } } } @@ -53,7 +58,9 @@ android { // • otherwise (incl. F-Droid) → UNSIGNED; F-Droid signs with its own key. signingConfig = when { ciKeystore != null -> signingConfigs.getByName("ci") - project.hasProperty("localSign") -> signingConfigs.getByName("debug") + // "debugConfig" (not AGP's built-in "debug") so -PlocalSign honours a + // repo-root debug.keystore the same way the debug build type does. + project.hasProperty("localSign") -> signingConfigs.getByName("debugConfig") else -> null } } @@ -92,9 +99,19 @@ android { viewBinding = true } lint { + // Kept non-fatal on purpose: the baseline is not clean yet, and CI runs `lintDebug` + // as its own step and uploads the report. Flip abortOnError to true (or add a + // lint-baseline.xml) once the outstanding warnings are fixed - see docs/CI_RELEASE.md. abortOnError = false checkReleaseBuilds = false } + + testOptions { + // The JVM unit tests cover pure logic (config JSON, credential hashing). Returning + // default values instead of throwing keeps an incidental android.util.Log or similar + // framework call from failing an otherwise valid test. + unitTests.isReturnDefaultValues = true + } } dependencies { diff --git a/app/src/main/java/com/gothwad/launcher/Actions.kt b/app/src/main/java/com/gothwad/launcher/Actions.kt index 8de83e6..111d3d3 100644 --- a/app/src/main/java/com/gothwad/launcher/Actions.kt +++ b/app/src/main/java/com/gothwad/launcher/Actions.kt @@ -18,10 +18,7 @@ object Actions { ?: pm.getLaunchIntentForPackage(pkg) if (intent != null) { runCatching { - if (context !is android.app.Activity) { - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) + context.startActivity(newTaskIfRequired(context, intent)) com.gothwad.launcher.data.AppLaunchTracker.onAppLaunched(pkg) }.onFailure { toast(context, context.getString(R.string.toast_cannot_open)) } } else toast(context, context.getString(R.string.toast_no_launchable)) @@ -30,9 +27,12 @@ object Actions { fun openAppInfo(context: Context, pkg: String) { runCatching { context.startActivity( - Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.parse("package:$pkg") + newTaskIfRequired( + context, + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:$pkg") + ) ) ) } @@ -40,10 +40,22 @@ object Actions { fun uninstall(context: Context, pkg: String) { runCatching { - context.startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$pkg"))) + context.startActivity( + newTaskIfRequired(context, Intent(Intent.ACTION_DELETE, Uri.parse("package:$pkg"))) + ) } } + /** + * `startActivity` from a non-Activity context throws `AndroidRuntimeException` unless the + * intent starts its own task. These two helpers were the only ones missing the flag, and the + * surrounding `runCatching` swallowed the throw - so "Open app details" / "Uninstall" silently + * did nothing when reached from a service, receiver or overlay. + */ + private fun newTaskIfRequired(context: Context, intent: Intent): Intent = intent.apply { + if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + /** Best-effort: clears the app from cached memory. True force-stop is in App info. */ fun close(context: Context, pkg: String) { val am = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager diff --git a/app/src/main/java/com/gothwad/launcher/GothwadApplication.kt b/app/src/main/java/com/gothwad/launcher/GothwadApplication.kt index 13e59e4..cc1c1ef 100644 --- a/app/src/main/java/com/gothwad/launcher/GothwadApplication.kt +++ b/app/src/main/java/com/gothwad/launcher/GothwadApplication.kt @@ -5,13 +5,13 @@ import android.app.Application import android.app.PendingIntent import android.content.ComponentCallbacks2 import android.content.Context -import android.content.Intent import android.os.Build import android.os.SystemClock import android.util.Log import com.gothwad.launcher.data.AppLaunchTracker import com.gothwad.launcher.data.ProcessHeartbeat import com.gothwad.launcher.data.SelfHealGuard +import com.gothwad.launcher.service.LauncherAccessibilityService import com.gothwad.launcher.service.LauncherWatchdogService import java.io.File import java.io.PrintWriter @@ -94,21 +94,20 @@ class GothwadApplication : Application() { private fun scheduleEmergencyRelaunch() { runCatching { - val intent = Intent(this, MainActivity::class.java).apply { - action = Intent.ACTION_MAIN - addCategory(Intent.CATEGORY_HOME) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED or - Intent.FLAG_ACTIVITY_SINGLE_TOP or - Intent.FLAG_ACTIVITY_CLEAR_TOP or - Intent.FLAG_ACTIVITY_REORDER_TO_FRONT - } + // Same intent every other recovery path uses (single definition, no drift). + val intent = LauncherAccessibilityService.homeIntent(this) val pendingIntent = PendingIntent.getActivity( this, RELAUNCH_REQUEST_CODE, intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + // FLAG_IMMUTABLE only exists from API 23 and minSdk is 21. + PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE + } else { + 0 + }, ) val alarmManager = getSystemService(Context.ALARM_SERVICE) as? AlarmManager diff --git a/app/src/main/java/com/gothwad/launcher/MainActivity.kt b/app/src/main/java/com/gothwad/launcher/MainActivity.kt index 502ae85..b8201a2 100644 --- a/app/src/main/java/com/gothwad/launcher/MainActivity.kt +++ b/app/src/main/java/com/gothwad/launcher/MainActivity.kt @@ -1,10 +1,13 @@ package com.gothwad.launcher +import android.Manifest import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +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 @@ -26,7 +29,6 @@ import android.view.KeyEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.util.Locale @@ -34,10 +36,6 @@ class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding - /** Bumped whenever a package is installed/removed so the app grid rescans. */ - var rescanTick: Int = 0 - private set - private var currentConfig: LauncherConfig = LauncherConfig() private var rescanJob: Job? = null private var statusBarController: MainStatusBarController? = null @@ -54,7 +52,6 @@ class MainActivity : AppCompatActivity() { packageManager.getLaunchIntentForPackage(pkg) != null ) if (intent.action == Intent.ACTION_PACKAGE_REMOVED || launchable) { - rescanTick++ scheduleAppsRescan() } } @@ -89,42 +86,24 @@ class MainActivity : AppCompatActivity() { } } + /** + * Rescans the installed apps and seeds the remote-button defaults. + * + * This used to also write an auto-category assignment for every newly installed package + * (`config.sections` + `config.knownApps`) - a JSON encode and a DataStore write on every + * scan, for a category grid that the home screen no longer renders and no settings screen + * exposes. That machinery is gone; the grid is a single flat list ordered by + * `config.order`. + */ private fun refreshAppsList() { lifecycleScope.launch(Dispatchers.IO) { val apps = AppRepository.scan(this@MainActivity) allApps = apps val store = ConfigStore(this@MainActivity) - val installedPackages = apps.map { it.pkg }.toSet() - com.gothwad.launcher.data.ButtonMappingManager.seedDefaultMappings(store, installedPackages) - - // Newly installed apps: drop them into their real auto-category instead of - // always into the first section. `knownApps` (previously a dead config field) - // is what makes "new since the last scan" detectable. - val config = runCatching { store.flow.first() }.getOrNull() ?: return@launch - val newPackages = installedPackages - config.knownApps - if (newPackages.isEmpty()) { - if (config.knownApps != installedPackages) { - store.update { it.copy(knownApps = installedPackages) } - } - return@launch - } - - store.update { cfg -> - val updatedSections = cfg.sections.toMutableMap() - if (cfg.autoCategoryOnInstall) { - val fallbackId = cfg.categories.firstOrNull()?.id - for (pkg in newPackages) { - if (updatedSections.containsKey(pkg)) continue - val auto = apps.firstOrNull { it.pkg == pkg }?.autoCategory - val target = when { - auto != null && cfg.categories.any { it.id == auto } -> auto - else -> fallbackId - } - if (target != null) updatedSections[pkg] = setOf(target) - } - } - cfg.copy(sections = updatedSections, knownApps = installedPackages) - } + com.gothwad.launcher.data.ButtonMappingManager.seedDefaultMappings( + store, + apps.map { it.pkg }.toSet(), + ) } } @@ -132,7 +111,7 @@ class MainActivity : AppCompatActivity() { // NOTE: there is no locale override here any more. The old code read a // "locale"/"lang" SharedPreferences entry that nothing ever wrote, so it was dead // code pretending to be an i18n feature. Real localization needs translated - // string resources first (see ISSUE-REPORT.md, issue #19). + // string resources first (see docs/ISSUE_LOG.md, issue #19). super.attachBaseContext(com.gothwad.launcher.ui.DensityAdapter.wrapContext(newBase)) } @@ -192,6 +171,30 @@ class MainActivity : AppCompatActivity() { private val bluetoothPermissionLauncher = registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.RequestPermission()) { /* status is refreshed by the BT flow */ } + /** Asked at most once per process; a denial is respected and never re-prompted. */ + private var bluetoothPermissionRequested = false + + /** + * Requests `BLUETOOTH_CONNECT` on Android 12+. + * + * The permission is declared in the manifest but is a *runtime* permission there, and + * nothing ever asked for it - so `bluetoothStatusFlow` could only report + * `connected = false, isRemote = false` ("unknown") and the dashboard's remote name and + * battery readout never worked on a modern TV. Called when the Quick Dashboard opens. + */ + fun ensureBluetoothPermission() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + if (bluetoothPermissionRequested) return + val permission = Manifest.permission.BLUETOOTH_CONNECT + if (ContextCompat.checkSelfPermission(this, permission) == + PackageManager.PERMISSION_GRANTED + ) { + return + } + bluetoothPermissionRequested = true + runCatching { bluetoothPermissionLauncher.launch(permission) } + } + private fun setupA11yWatchdogReceiver() { // Register receiver for watchdog alerts ContextCompat.registerReceiver( @@ -218,6 +221,7 @@ class MainActivity : AppCompatActivity() { private fun handleAppLaunch(app: AppEntry) { // App lock & hidden vault gate AppLockGate.evaluate( + context = this, fragmentManager = supportFragmentManager, app = app, config = currentConfig, 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 212cbd9..41f6faa 100644 --- a/app/src/main/java/com/gothwad/launcher/data/AppRepository.kt +++ b/app/src/main/java/com/gothwad/launcher/data/AppRepository.kt @@ -2,7 +2,6 @@ package com.gothwad.launcher.data import android.content.Context import android.content.Intent -import android.content.pm.ApplicationInfo import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Build @@ -12,6 +11,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext import java.io.File data class AppEntry( @@ -20,7 +20,6 @@ data class AppEntry( /** 16:9 leanback banner if the app ships one, pre-rasterized */ val banner: Bitmap?, val icon: Bitmap?, - val autoCategory: String, /** Tile color precomputed at scan time — ARGB int. */ val tile: Int, /** lastUpdateTime of the package — used to reuse entries across rescans. */ @@ -31,33 +30,11 @@ data class AppEntry( object AppRepository { - /** Reserved id for the auto-filled "All apps" section (every launchable app). */ - const val ALL_APPS_ID = "__all__" - - /** Well-known packages -> category id */ - private val KNOWN = mapOf( - "com.netflix.ninja" to "streaming", - "com.google.android.youtube.tv" to "streaming", - "com.amazon.amazonvideo.livingroom" to "streaming", - "com.disney.disneyplus" to "streaming", - "com.hbo.hbonow" to "streaming", - "com.wbd.stream" to "streaming", - "com.plexapp.android" to "streaming", - "org.videolan.vlc" to "streaming", - "tv.emby.embyatv" to "streaming", - "com.jellyfin.androidtv" to "streaming", - "com.spotify.tv.android" to "music", - "com.pandora.android.atv" to "music", - "deezer.android.tv" to "music", - "com.retroarch" to "games", - "com.retroarch.aarch64" to "games", - "org.ppsspp.ppsspp" to "games", - "org.dolphinemu.dolphinemu" to "games", - "com.valvesoftware.steamlink" to "games", - "com.nvidia.geforcenow" to "games", - "com.moonlight_stream.moonlight" to "games", - ) - + /** + * Read from whichever thread calls [scan] and written from an IO coroutine, so it must + * be `@Volatile` - otherwise a main-thread caller can keep observing a stale list. + */ + @Volatile private var memoryCache: List? = null /** @@ -68,7 +45,11 @@ 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 = coroutineScope { + suspend fun scan(context: Context): List = withContext(Dispatchers.IO) { + scanInternal(context) + } + + private suspend fun scanInternal(context: Context): List = coroutineScope { val pm = context.packageManager val cacheDir = File(context.cacheDir, "app_art").apply { mkdirs() } @@ -109,7 +90,9 @@ object AppRepository { if (valid) return@coroutineScope cached } - val validCacheNames = HashSet(byPkg.size * 2) + // Mutated from several `async(Dispatchers.IO)` blocks at once - a plain HashSet + // would corrupt (and then the prune step below deletes artwork that is still live). + val validCacheNames = java.util.Collections.synchronizedSet(HashSet(byPkg.size * 2)) val entries = byPkg.values.map { ri -> async(Dispatchers.IO) { @@ -153,7 +136,6 @@ object AppRepository { label = runCatching { ri.loadLabel(pm)?.toString() }.getOrNull() ?: pkg, banner = banner, icon = icon, - autoCategory = autoCategory(ai.applicationInfo), tile = tileColor(pkg), stamp = stamp, firstInstall = firstInstall, @@ -166,7 +148,10 @@ object AppRepository { if (f.name !in validCacheNames) runCatching { f.delete() } } val result = entries.sortedBy { it.label.lowercase() } - if (result == memoryCache) return@coroutineScope memoryCache!! + // Single volatile read: `clearMemoryCache()` on another thread must not be able to + // turn this into a NullPointerException. + val previous = memoryCache + if (previous != null && previous == result) return@coroutineScope previous memoryCache = result result } @@ -200,24 +185,6 @@ object AppRepository { return bmp } - private fun autoCategory(ai: ApplicationInfo?): String { - ai ?: return "apps" - KNOWN[ai.packageName]?.let { return it } - if (Build.VERSION.SDK_INT >= 26) { - when (ai.category) { - ApplicationInfo.CATEGORY_VIDEO -> return "streaming" - ApplicationInfo.CATEGORY_GAME -> return "games" - ApplicationInfo.CATEGORY_AUDIO -> return "music" - ApplicationInfo.CATEGORY_IMAGE -> return "apps" - ApplicationInfo.CATEGORY_NEWS -> return "apps" - ApplicationInfo.CATEGORY_MAPS -> return "apps" - ApplicationInfo.CATEGORY_PRODUCTIVITY -> return "apps" - } - } - if ((ai.flags and ApplicationInfo.FLAG_IS_GAME) != 0) return "games" - return "apps" - } - fun clearMemoryCache() { memoryCache = null } diff --git a/app/src/main/java/com/gothwad/launcher/data/AppUpdateManager.kt b/app/src/main/java/com/gothwad/launcher/data/AppUpdateManager.kt index 483a023..159123b 100644 --- a/app/src/main/java/com/gothwad/launcher/data/AppUpdateManager.kt +++ b/app/src/main/java/com/gothwad/launcher/data/AppUpdateManager.kt @@ -9,12 +9,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject -import java.io.BufferedReader import java.io.File import java.io.FileOutputStream -import java.io.InputStreamReader import java.net.HttpURLConnection +import java.net.URI import java.net.URL +import java.util.concurrent.TimeUnit data class GitHubReleaseInfo( val tagName: String, @@ -44,20 +44,73 @@ object AppUpdateManager { private const val GITHUB_REPO = "launcher" private const val RELEASES_API_URL = "https://api.github.com/repos/$GITHUB_OWNER/$GITHUB_REPO/releases/latest" + private const val USER_AGENT = "GothwadLauncher-AndroidTV" + private const val CONNECT_TIMEOUT_MS = 15_000 + private const val READ_TIMEOUT_MS = 30_000 + private const val MAX_REDIRECTS = 5 + + /** + * A shelled-out `su` can block for as long as a root prompt waits for the user, so every + * subprocess gets a hard deadline instead of an unbounded `waitFor()`. + */ + private const val SHELL_TIMEOUT_MS = 8_000L + + /** How this device is able to install a downloaded update. */ + enum class InstallCapability { ROOT_SHELL, PACKAGE_INSTALLER, NEEDS_PERMISSION } + /** * Checks if current app has root or local shell execution ability. * On TVs rooted or running local adb port / test-keys / su, commands succeed. */ - fun hasRootOrAdbShell(): Boolean { + fun hasRootOrAdbShell(): Boolean = + runShell(arrayOf("su", "-c", "id"))?.first == 0 + + /** + * Runs [command] with merged stdout/stderr and a hard timeout. + * + * @return exit code to output, or null when the command could not run or timed out. + */ + private fun runShell(command: Array): Pair? { + var process: Process? = null return try { - val p = Runtime.getRuntime().exec(arrayOf("su", "-c", "id")) - val exit = p.waitFor() - exit == 0 + process = ProcessBuilder(*command).redirectErrorStream(true).start() + // Drain the single merged stream first: reading stdout to EOF and *then* waiting is + // what avoids the classic full-pipe deadlock. + val output = process.inputStream.bufferedReader().use { it.readText() } + val finished = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + process.waitFor(SHELL_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } else { + process.waitFor() + true + } + if (!finished) { + process.destroy() + null + } else { + process.exitValue() to output + } } catch (_: Exception) { - false + null + } finally { + runCatching { process?.destroy() } } } + /** + * Resolves how this device can install an update, off the main thread. + * + * `hasRootOrAdbShell()` shells out to `su`; the Settings sheet used to call it directly on + * the UI thread, which hangs (ANR) on any box where `su` exists and shows a prompt. + */ + suspend fun detectInstallCapability(context: Context): InstallCapability = + withContext(Dispatchers.IO) { + when { + hasRootOrAdbShell() -> InstallCapability.ROOT_SHELL + canRequestPackageInstalls(context) -> InstallCapability.PACKAGE_INSTALLER + else -> InstallCapability.NEEDS_PERMISSION + } + } + /** * Checks if can install packages directly via normal package installer */ @@ -73,21 +126,19 @@ object AppUpdateManager { * Fetches the latest release details from GitHub API for GothwadTech/launcher */ suspend fun checkLatestRelease(currentVersionName: String): UpdateCheckResult = withContext(Dispatchers.IO) { + var conn: HttpURLConnection? = null try { - val url = URL(RELEASES_API_URL) - val conn = url.openConnection() as HttpURLConnection - conn.connectTimeout = 12000 - conn.readTimeout = 12000 + conn = URL(RELEASES_API_URL).openConnection() as HttpURLConnection + conn.connectTimeout = CONNECT_TIMEOUT_MS + conn.readTimeout = READ_TIMEOUT_MS conn.setRequestProperty("Accept", "application/vnd.github.v3+json") - conn.setRequestProperty("User-Agent", "GothwadLauncher-AndroidTV") + conn.setRequestProperty("User-Agent", USER_AGENT) if (conn.responseCode != 200) { return@withContext UpdateCheckResult.Error("GitHub response error (${conn.responseCode}): ${conn.responseMessage}") } - val reader = BufferedReader(InputStreamReader(conn.inputStream)) - val jsonStr = reader.readText() - reader.close() + val jsonStr = conn.inputStream.bufferedReader().use { it.readText() } val root = JSONObject(jsonStr) val tagName = root.optString("tag_name", "") @@ -136,11 +187,14 @@ object AppUpdateManager { } } catch (e: Exception) { UpdateCheckResult.Error("Failed to check update: ${e.localizedMessage ?: e.javaClass.simpleName}") + } finally { + runCatching { conn?.disconnect() } } } /** - * Download the APK to the application cache updates directory + * Downloads the APK into the application cache `updates/` directory (the path exposed by + * `res/xml/file_paths.xml` for the FileProvider install intent). */ suspend fun downloadApk( context: Context, @@ -148,6 +202,7 @@ object AppUpdateManager { targetFileName: String, onProgress: (Int) -> Unit ): Result = withContext(Dispatchers.IO) { + var conn: HttpURLConnection? = null try { val updateDir = File(context.cacheDir, "updates") if (!updateDir.exists()) updateDir.mkdirs() @@ -155,86 +210,116 @@ object AppUpdateManager { val outFile = File(updateDir, targetFileName) if (outFile.exists()) outFile.delete() - val url = URL(downloadUrl) - val conn = url.openConnection() as HttpURLConnection - conn.connectTimeout = 15000 - conn.readTimeout = 30000 - conn.instanceFollowRedirects = true - conn.setRequestProperty("User-Agent", "GothwadLauncher-AndroidTV") - - var responseCode = conn.responseCode - var finalConn = conn - if (responseCode == HttpURLConnection.HTTP_MOVED_TEMP || responseCode == HttpURLConnection.HTTP_MOVED_PERM || responseCode == 307 || responseCode == 308) { - val redirectUrl = conn.getHeaderField("Location") - val redUrl = URL(redirectUrl) - finalConn = redUrl.openConnection() as HttpURLConnection - finalConn.connectTimeout = 15000 - finalConn.readTimeout = 30000 - finalConn.setRequestProperty("User-Agent", "GothwadLauncher-AndroidTV") - } - - val fileLength = finalConn.contentLength - val input = finalConn.inputStream - val output = FileOutputStream(outFile) + conn = openFollowingRedirects(downloadUrl) + ?: return@withContext Result.failure( + IllegalArgumentException("Refusing to download from: $downloadUrl") + ) - val buffer = ByteArray(8192) - var total: Long = 0 - var count: Int + val code = conn.responseCode + if (code !in 200..299) { + return@withContext Result.failure( + IllegalStateException("Download failed with HTTP $code") + ) + } - while (input.read(buffer).also { count = it } != -1) { - total += count - if (fileLength > 0) { - val progress = ((total * 100) / fileLength).toInt() - withContext(Dispatchers.Main) { - onProgress(progress) + val fileLength = conn.contentLength + var lastReported = -1 + + conn.inputStream.use { input -> + FileOutputStream(outFile).use { output -> + val buffer = ByteArray(8192) + var total = 0L + while (true) { + val count = input.read(buffer) + if (count == -1) break + total += count + output.write(buffer, 0, count) + + if (fileLength > 0) { + val progress = ((total * 100) / fileLength).toInt().coerceIn(0, 100) + // One main-thread hop per percent instead of one per 8 KB chunk. + if (progress != lastReported) { + lastReported = progress + withContext(Dispatchers.Main) { onProgress(progress) } + } + } } + output.flush() } - output.write(buffer, 0, count) } - output.flush() - output.close() - input.close() - Result.success(outFile) } catch (e: Exception) { Result.failure(e) + } finally { + runCatching { conn?.disconnect() } } } /** - * Tries silent self-installation via ADB shell / root command. - * Commands: pm install -r + * Opens [url], following up to [MAX_REDIRECTS] hops manually. + * + * `HttpURLConnection.instanceFollowRedirects` does not cross hosts, and GitHub release + * assets redirect from github.com to a signed objects.githubusercontent.com URL, so the hop + * has to be handled here. Only http(s) targets are followed: anything else (`file:`, + * `content:`, `jar:`) is rejected instead of opened, because what comes back is handed to a + * package installer. + */ + private fun openFollowingRedirects(url: String): HttpURLConnection? { + var current = url + repeat(MAX_REDIRECTS) { + val scheme = runCatching { URI(current).scheme?.lowercase() }.getOrNull() + if (scheme != "https" && scheme != "http") return null + + val conn = URL(current).openConnection() as HttpURLConnection + conn.connectTimeout = CONNECT_TIMEOUT_MS + conn.readTimeout = READ_TIMEOUT_MS + conn.instanceFollowRedirects = false + conn.setRequestProperty("User-Agent", USER_AGENT) + + val code = runCatching { conn.responseCode }.getOrDefault(-1) + if (code in 300..399) { + val location = conn.getHeaderField("Location") + conn.disconnect() + if (location.isNullOrEmpty()) return null + current = URL(URL(current), location).toString() + return@repeat + } + return conn + } + return null + } + + /** + * Tries a silent self-install through root / a local shell (`pm install -r `). + * + * Every command is bounded by [SHELL_TIMEOUT_MS]; an unrooted box where `su` exists but + * waits for a user prompt used to block here forever. */ suspend fun installViaAdbShell(apkFile: File): InstallResult = withContext(Dispatchers.IO) { try { - // Make file world readable so shell / pm process can read it + // Make the file readable by the shell / pm process performing the install. apkFile.setReadable(true, false) + val path = apkFile.absolutePath val commands = listOf( - arrayOf("su", "-c", "pm install -r \"${apkFile.absolutePath}\""), - arrayOf("sh", "-c", "pm install -r \"${apkFile.absolutePath}\""), - arrayOf("pm", "install", "-r", apkFile.absolutePath) + arrayOf("su", "-c", "pm install -r \"$path\""), + arrayOf("sh", "-c", "pm install -r \"$path\""), + arrayOf("pm", "install", "-r", path), ) var lastError = "No command succeeded" for (cmd in commands) { - try { - val proc = Runtime.getRuntime().exec(cmd) - val reader = BufferedReader(InputStreamReader(proc.inputStream)) - val errReader = BufferedReader(InputStreamReader(proc.errorStream)) - val out = reader.readText() - val err = errReader.readText() - val exit = proc.waitFor() - - if (exit == 0 && (out.contains("Success", ignoreCase = true) || err.contains("Success", ignoreCase = true))) { - return@withContext InstallResult.SuccessAdb - } else { - lastError = (out + " " + err).trim() - } - } catch (e: Exception) { - lastError = e.localizedMessage ?: "Command failed" + val result = runShell(cmd) + if (result == null) { + lastError = "${cmd.first()} timed out or could not be executed" + continue + } + val (exit, output) = result + if (exit == 0 && output.contains("Success", ignoreCase = true)) { + return@withContext InstallResult.SuccessAdb } + lastError = output.trim().ifEmpty { "exit code $exit" } } InstallResult.Error("ADB/Shell install not permitted on this TV: $lastError") diff --git a/app/src/main/java/com/gothwad/launcher/data/CategoryAssigner.kt b/app/src/main/java/com/gothwad/launcher/data/CategoryAssigner.kt deleted file mode 100644 index 5b2f432..0000000 --- a/app/src/main/java/com/gothwad/launcher/data/CategoryAssigner.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.gothwad.launcher.data - -/** - * Pure category/visibility rules for the app grid. - * - * Extracted from `TvLauncherFragment` (issue #38): this logic decides what the user sees - * on their home screen - which app lands in which section and whether a hidden (vault) - * app is rendered at all - and it was previously untestable inside a fragment. It has no - * Android dependencies, so it is covered by JVM unit tests in `app/src/test`. - */ -object CategoryAssigner { - - /** Apps that should be rendered for [config] (vault apps only when "show hidden" is on). */ - fun visibleApps(apps: List, config: LauncherConfig): List = - if (config.showHidden) apps else apps.filter { it.pkg !in config.hidden } - - /** Sections a single app belongs to, honouring explicit assignment first. */ - fun sectionsFor( - app: AppEntry, - config: LauncherConfig, - fallbackCategoryId: String, - ): Set { - config.sections[app.pkg]?.let { return it } - if (!config.autoCategoryOnInstall) return setOf(fallbackCategoryId) - - val auto = app.autoCategory - val target = if (config.categories.any { it.id == auto }) auto else fallbackCategoryId - return setOf(target) - } - - /** - * Builds the ordered rows for the grid: one row per category, apps ordered by the - * user's saved order first and then by first-install time. - * Categories with no visible apps are skipped. - */ - fun rows(apps: List, config: LauncherConfig): List>> { - if (apps.isEmpty()) return emptyList() - - val categories = config.categories.ifEmpty { listOf(CategoryCfg("apps", "Apps")) } - val fallbackCategoryId = categories.first().id - val visible = visibleApps(apps, config) - - return categories.mapNotNull { cat -> - val inCategory = if (cat.id == AppRepository.ALL_APPS_ID) { - visible - } else { - visible.filter { app -> cat.id in sectionsFor(app, config, fallbackCategoryId) } - } - - val explicit = config.order[cat.id] - val ordered = if (explicit.isNullOrEmpty()) { - inCategory.sortedWith(compareBy({ it.firstInstall }, { it.label.lowercase() })) - } else { - val byPkg = inCategory.associateBy { it.pkg } - val orderedFirst = explicit.mapNotNull { byPkg[it] } - val remaining = inCategory - .filter { it.pkg !in explicit.toSet() } - .sortedWith(compareBy({ it.firstInstall }, { it.label.lowercase() })) - orderedFirst + remaining - } - - if (ordered.isEmpty()) null else cat to ordered - } - } -} 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 1f7020f..89f7cf2 100644 --- a/app/src/main/java/com/gothwad/launcher/data/Config.kt +++ b/app/src/main/java/com/gothwad/launcher/data/Config.kt @@ -4,7 +4,6 @@ import android.content.Context import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore -import com.gothwad.launcher.R import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.serialization.Serializable @@ -13,19 +12,11 @@ import kotlinx.serialization.json.Json /** Spacing steps between cards — 5 levels (in dp). */ val GAP_SIZES: List = listOf(4f, 10f, 16f, 24f, 32f) -/** Card icon size steps — 5 levels (in dp). */ -val ICON_SIZES: List = listOf(120f, 150f, 190f, 230f, 270f) /** Icon corner roundness steps (index stored in config.cornerRadius, in dp). */ val CORNER_RADII: List = listOf(0f, 4f, 10f, 18f, 28f) -/** Manual whole-UI scale steps (config.uiScale 1..5). <1 = more compact. */ +/** Manual whole-UI scale steps (config.uiScale indexes this, 0..4). <1 = more compact. */ val UI_SCALES: List = listOf(0.75f, 0.9f, 1.0f, 1.15f, 1.3f) -@Serializable -data class CategoryCfg( - val id: String, - val name: String, -) - enum class LockCredentialType { NUMERIC, ALPHANUMERIC } @Serializable @@ -57,20 +48,19 @@ data class LockCredential( @Serializable data class LauncherConfig( - val categories: List = listOf( - CategoryCfg("streaming", "Streaming"), - CategoryCfg("games", "Games"), - CategoryCfg("music", "Music"), - CategoryCfg("apps", "Apps"), - ), - /** package -> section ids the user assigned (an app may be in several) */ - val sections: Map> = emptyMap(), - /** category id -> explicit package order */ + /** + * Explicit package order for the home grid (package list, flattened by + * `TvLauncherFragment.rebuildAppList`). Everything not listed follows, sorted by + * first-install time. + * + * NOTE: this used to be keyed per category (`Map>`). The category + * rows were removed from the home screen, but the map shape is kept so configs written by + * older versions still decode (`Json { ignoreUnknownKeys = true }` drops the rest). + */ val order: Map> = emptyMap(), val hidden: Set = emptySet(), - /** packages seen on the last scan — new installs are auto-added to the first section */ - val knownApps: Set = emptySet(), - val wallpaper: Int = 1, // Aurora + /** Index into `WALLPAPERS` (ui/Theme.kt); see `DEFAULT_WALLPAPER_INDEX`. */ + val wallpaper: Int = 1, val useCustomWallpaper: Boolean = false, val accent: Int = 0, val h24: Boolean = false, @@ -78,26 +68,21 @@ data class LauncherConfig( val setupDone: Boolean = false, // ----- status bar ----- val showStatusBar: Boolean = true, - /** package of the app the VPN icon opens; empty = system VPN settings */ - val vpnApp: String = "", - val showVpnButton: Boolean = true, /** wrap the status-bar icons in the same glass panel as dock mode */ val statusBarGlass: Boolean = true, /** Match status bar header cards corner curve to app icon curve */ val headerMatchIconCorners: Boolean = true, // ----- layout & visuals ----- - val layout: Int = LAYOUT_GRID, /** spacing step 0..4 (see GAP_SIZES) */ val spacing: Int = 2, - /** icon size step 0..4 (see ICON_SIZES) */ - val iconScale: Int = 2, /** icon/panel corner roundness step 0..4 (see CORNER_RADII) */ val cornerRadius: Int = 2, - /** whole-UI scale step 1..5 (see UI_SCALES, default 3 = 1.0x) */ - val uiScale: Int = 3, - val showCategoryNames: Boolean = true, - val showAppLabels: Boolean = true, - /** 0 = full, 1 = top only, 2 = bottom only, 3 = both, 4 = none */ + /** + * Whole-UI scale step 0..4 (see UI_SCALES, default 2 = 1.0x). Applied to the density by + * `DensityAdapter` - see `MainStatusBarController.applyDensityPolicy`. + */ + val uiScale: Int = 2, + /** 0 = full screen, 1 = top edge, 2 = bottom edge, 3 = both edges, 4 = none */ val scrimMode: Int = 0, // ----- behavior ----- /** Boot-ad shield / force-launch home at boot. Discoverable in Settings > Apps. */ @@ -110,29 +95,47 @@ data class LauncherConfig( val aggressiveMemoryTrim: Boolean = false, /** Honour the system font-size (accessibility) setting instead of forcing 1.0x. */ val respectSystemFontScale: Boolean = true, - val autoCategoryOnInstall: Boolean = true, /** app lock: list of package names requiring a PIN to open */ val lockedApps: Set = emptySet(), val appLock: LockCredential = LockCredential(), val hiddenAppsLock: LockCredential = LockCredential(), + /** + * LEGACY plain-text hidden-apps reveal code. Kept only so an existing install can be + * migrated to [hiddenAppsRevealCodeHash] on first launch (see + * `LockSecurity.migrateLegacyCredentials`); it is cleared by that migration and must + * never be written again. + */ val hiddenAppsRevealCode: String = "", + /** Base64 PBKDF2 hash of the search-bar reveal code. */ + val hiddenAppsRevealCodeHash: String = "", + /** Base64 random salt for [hiddenAppsRevealCodeHash]. */ + val hiddenAppsRevealCodeSalt: String = "", // ----- Remote Button Mapping (Phase 5) ----- val buttonMap: Map = emptyMap(), val buttonMapDefaultsApplied: Boolean = false, ) -const val LAYOUT_GRID = 0 - private val Context.dataStore by preferencesDataStore(name = "launcher_config") class ConfigStore(private val context: Context) { + private companion object { + const val TAG = "ConfigStore" + } + private val key = stringPreferencesKey("config_json") private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } val flow: Flow = context.dataStore.data.map { prefs -> - prefs[key]?.let { - runCatching { json.decodeFromString(it) }.getOrNull() - } ?: LauncherConfig() + val stored = prefs[key] ?: return@map LauncherConfig() + runCatching { json.decodeFromString(stored) } + .onFailure { e -> + // A failed decode means the launcher silently comes up with factory defaults, + // which for a home screen looks like "all my settings and locks disappeared". + // Log it loudly: the raw JSON is still in the DataStore file and is recoverable + // from a bug report. + android.util.Log.e(TAG, "Config decode failed - falling back to defaults", e) + } + .getOrDefault(LauncherConfig()) } suspend fun update(transform: (LauncherConfig) -> LauncherConfig) { diff --git a/app/src/main/java/com/gothwad/launcher/data/LockSecurity.kt b/app/src/main/java/com/gothwad/launcher/data/LockSecurity.kt index f8435b8..808aaba 100644 --- a/app/src/main/java/com/gothwad/launcher/data/LockSecurity.kt +++ b/app/src/main/java/com/gothwad/launcher/data/LockSecurity.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.first import kotlin.io.encoding.Base64 as KotlinBase64 import java.security.MessageDigest import java.security.SecureRandom +import java.util.Locale import javax.crypto.SecretKeyFactory import javax.crypto.spec.PBEKeySpec @@ -136,16 +137,27 @@ object LockSecurity { Log.i(TAG, "Migrating legacy plain-text lock credentials to PBKDF2 hashes") store.update { cfg -> - cfg.copy( - appLock = migrateOne(cfg.appLock), - hiddenAppsLock = migrateOne(cfg.hiddenAppsLock), + migrateRevealCode( + cfg.copy( + appLock = migrateOne(cfg.appLock), + hiddenAppsLock = migrateOne(cfg.hiddenAppsLock), + ) ) } } private fun hasLegacyPlainText(config: LauncherConfig): Boolean = (config.appLock.value.isNotEmpty() && config.appLock.credentialHash.isEmpty()) || - (config.hiddenAppsLock.value.isNotEmpty() && config.hiddenAppsLock.credentialHash.isEmpty()) + (config.hiddenAppsLock.value.isNotEmpty() && config.hiddenAppsLock.credentialHash.isEmpty()) || + (config.hiddenAppsRevealCode.isNotEmpty() && config.hiddenAppsRevealCodeHash.isEmpty()) + + /** Hashes and clears a legacy plain-text search-bar reveal code. */ + private fun migrateRevealCode(config: LauncherConfig): LauncherConfig = + if (config.hiddenAppsRevealCode.isEmpty() || config.hiddenAppsRevealCodeHash.isNotEmpty()) { + config + } else { + applyRevealCode(config, config.hiddenAppsRevealCode) + } private fun migrateOne(credential: LockCredential): LockCredential { if (credential.value.isEmpty() || credential.credentialHash.isNotEmpty()) return credential @@ -157,6 +169,59 @@ object LockSecurity { ) } + // ========================================================================= + // Hidden-apps reveal code (typed into the search bar) + // ========================================================================= + + /** + * The reveal code used to live in the config as plain text, which contradicted the + * "never plain text" rule the rest of this file exists to enforce: it sat in the same + * DataStore JSON as the hashed PINs and was echoed back into an editable text field. + * It is now stored exactly like a credential - salted PBKDF2, never reversible - so the + * Settings screen can tell *whether* a code is set but can no longer reveal it. + * + * Matching stays case-insensitive (that was the pre-hashing behaviour and it matters on a + * TV remote), so the candidate is normalised before it is hashed. + */ + private fun normalizeRevealCode(code: String): String = + code.trim().lowercase(Locale.ROOT) + + /** True when a hashed reveal code is configured. */ + fun isRevealCodeSet(config: LauncherConfig): Boolean = + config.hiddenAppsRevealCodeHash.isNotBlank() && config.hiddenAppsRevealCodeSalt.isNotBlank() + + /** Constant-time check of a search-bar candidate against the stored reveal code. */ + fun verifyRevealCode(config: LauncherConfig, candidate: String): Boolean { + if (candidate.isBlank() || !isRevealCodeSet(config)) return false + val computed = hash(normalizeRevealCode(candidate), config.hiddenAppsRevealCodeSalt) + if (computed.isBlank()) return false + return MessageDigest.isEqual( + computed.toByteArray(Charsets.UTF_8), + config.hiddenAppsRevealCodeHash.toByteArray(Charsets.UTF_8), + ) + } + + /** + * Returns [config] with [code] stored as a fresh salted hash. An empty/blank [code] + * clears the reveal code entirely. The plain-text legacy field is always wiped. + */ + fun applyRevealCode(config: LauncherConfig, code: String): LauncherConfig { + val normalized = normalizeRevealCode(code) + if (normalized.isEmpty()) { + return config.copy( + hiddenAppsRevealCode = "", + hiddenAppsRevealCodeHash = "", + hiddenAppsRevealCodeSalt = "", + ) + } + val salt = randomSalt() + return config.copy( + hiddenAppsRevealCode = "", + hiddenAppsRevealCodeHash = hash(normalized, salt), + hiddenAppsRevealCodeSalt = salt, + ) + } + // ========================================================================= // Brute-force throttle (per lock scope: "device", "app", "vault") // ========================================================================= 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 9123e57..987382f 100644 --- a/app/src/main/java/com/gothwad/launcher/data/ProcessHeartbeat.kt +++ b/app/src/main/java/com/gothwad/launcher/data/ProcessHeartbeat.kt @@ -1,6 +1,7 @@ package com.gothwad.launcher.data import android.content.Context +import android.os.Build import android.os.Handler import android.os.Looper import android.os.SystemClock @@ -18,8 +19,9 @@ import java.io.File * returns `null`/an incomplete list for third-party apps, which made the launcher * steal the screen away from every other app every 10 seconds. * - * Instead we publish two monotonically increasing timestamps to a tiny file inside - * `filesDir` (visible to both processes, same UID): + * Instead we publish two monotonically increasing timestamps to a tiny file in + * device-protected storage (visible to both processes, same UID, and readable before the + * user unlocks the device - see [sharedDir]): * * - `process` beat: written by the main process application object, every few seconds, * regardless of UI state. If this goes stale, the process is genuinely dead/hung. @@ -34,6 +36,7 @@ object ProcessHeartbeat { private const val TAG = "ProcessHeartbeat" private const val FILE_NAME = "gothwad_heartbeat" + private const val POLICY_FILE_NAME = "gothwad_relaunch_policy" /** How often the main process publishes a beat. */ const val BEAT_INTERVAL_MS = 5_000L @@ -41,6 +44,23 @@ object ProcessHeartbeat { /** A main-process beat older than this means the process is dead or hung. */ const val STALE_PROCESS_MS = 45_000L + /** + * Directory for state shared between the main process and `:watchdog`. + * + * It has to be *device-protected* storage: the watchdog is `directBootAware` and can be + * started from `LOCKED_BOOT_COMPLETED`, when credential-encrypted `filesDir` is not mounted + * yet. Every read there throws, the throw is swallowed by `runCatching`, and the watchdog + * concludes "no heartbeat = the main process is dead" - so it yanked the TV back to HOME + * before the device was even unlocked. + */ + internal fun sharedDir(context: Context): File = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + context.createDeviceProtectedStorageContext().filesDir + } else { + context.filesDir + } + }.getOrDefault(context.filesDir) + private var handler: Handler? = null private val beatRunnable = object : Runnable { @@ -79,7 +99,7 @@ object ProcessHeartbeat { fun touch(context: Context, foreground: Boolean) { runCatching { val now = SystemClock.elapsedRealtime() - val file = File(context.filesDir, FILE_NAME) + 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). @@ -90,10 +110,31 @@ object ProcessHeartbeat { } /** Last main-process beat (elapsedRealtime), or 0 when unknown. */ - fun lastProcessBeat(context: Context): Long = readPair(File(context.filesDir, FILE_NAME)).first + fun lastProcessBeat(context: Context): Long = readPair(File(sharedDir(context), FILE_NAME)).first /** Last foreground (MainActivity STARTED) beat, or 0 when unknown. */ - fun lastForegroundBeat(context: Context): Long = readPair(File(context.filesDir, FILE_NAME)).second + fun lastForegroundBeat(context: Context): Long = readPair(File(sharedDir(context), FILE_NAME)).second + + /** + * Publishes whether the watchdog may pull the launcher to the foreground. + * + * Mirrors `LauncherConfig.launchOnBoot` (plus "are we the default home app?") into a file, + * because the `:watchdog` process must not touch the Preferences DataStore: DataStore is + * single-process by design and two processes on one file can corrupt it. + */ + fun setRelaunchAllowed(context: Context, allowed: Boolean) { + runCatching { + File(sharedDir(context), POLICY_FILE_NAME).writeText(if (allowed) "1" else "0") + } + } + + /** + * Last published policy. Defaults to "allowed" when nothing has been written yet, so crash + * recovery still works on a fresh install before the first config emission. + */ + fun isRelaunchAllowed(context: Context): Boolean = runCatching { + File(sharedDir(context), POLICY_FILE_NAME).readText().trim() != "0" + }.getOrDefault(true) /** True when the main process looks dead or hung. Safe against missing/corrupt state. */ fun isMainProcessStale(context: Context): Boolean { @@ -138,7 +179,7 @@ object SelfHealGuard { // File-backed (not SharedPreferences) on purpose: the crash handler lives in the // main process and the watchdog in `:watchdog`, and SharedPreferences instances are // cached per process, so cross-process counters would silently drift apart. - private fun file(context: Context) = File(context.filesDir, FILE_NAME) + private fun file(context: Context) = File(ProcessHeartbeat.sharedDir(context), FILE_NAME) private fun read(context: Context): Triple { return runCatching { diff --git a/app/src/main/java/com/gothwad/launcher/receiver/BootReceiver.kt b/app/src/main/java/com/gothwad/launcher/receiver/BootReceiver.kt index cf8cec9..b47406a 100644 --- a/app/src/main/java/com/gothwad/launcher/receiver/BootReceiver.kt +++ b/app/src/main/java/com/gothwad/launcher/receiver/BootReceiver.kt @@ -8,9 +8,11 @@ import com.gothwad.launcher.data.ConfigStore import com.gothwad.launcher.service.BootShieldService import com.gothwad.launcher.service.LauncherAccessibilityService import com.gothwad.launcher.service.LauncherWatchdogService +import com.gothwad.launcher.service.StockLauncherDetector import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -55,7 +57,8 @@ class BootReceiver : BroadcastReceiver() { // 2. Respect the user's "Launch on boot" preference (default: on, discoverable in // Settings > Apps; the shield is what keeps OEM boot ads off the screen). val pendingResult = goAsync() - val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val job = SupervisorJob() + val scope = CoroutineScope(job + Dispatchers.Default) scope.launch { try { val config = runCatching { ConfigStore(context).flow.first() }.getOrNull() @@ -65,7 +68,7 @@ class BootReceiver : BroadcastReceiver() { BootShieldService.start(context) } else { Log.i(TAG, "launchOnBoot disabled - only launching Home if we are the default HOME app") - if (isDefaultHomeApp(context)) { + if (StockLauncherDetector.isDefaultHomeApp(context)) { LauncherAccessibilityService.launchHomeWithRetry(context, maxAttempts = 2) } } @@ -73,19 +76,13 @@ class BootReceiver : BroadcastReceiver() { Log.e(TAG, "Error while starting boot handling", e) } finally { pendingResult.finish() + // The scope only ever runs this one job; cancel it so the SupervisorJob and the + // Default-dispatcher thread it holds are released instead of leaking per boot. + job.cancel() } } } - /** True when this package currently resolves as the system HOME app. */ - private fun isDefaultHomeApp(context: Context): Boolean { - return runCatching { - val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME) - val resolve = context.packageManager.resolveActivity(intent, 0) - resolve?.activityInfo?.packageName == context.packageName - }.getOrDefault(false) - } - companion object { private const val TAG = "BootReceiver" } 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 b4e2d2a..d92d7bc 100644 --- a/app/src/main/java/com/gothwad/launcher/service/BootShieldService.kt +++ b/app/src/main/java/com/gothwad/launcher/service/BootShieldService.kt @@ -11,6 +11,7 @@ import android.os.IBinder import android.util.Log import com.gothwad.launcher.data.BackgroundMediaTracker import com.gothwad.launcher.data.UsageTracker +import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -112,7 +113,14 @@ class BootShieldService : Service() { }.getOrDefault(4.0) return if (totalRamGb <= 2.2) { - Log.i(TAG, "Low RAM device detected (${"%.2f".format(totalRamGb)} GB). Extending boot shield to 90s.") + // Locale.ROOT: this is a log line, and a locale with a decimal comma would + // make the number unreadable in bug reports. + Log.i( + TAG, + "Low RAM device detected (" + + String.format(Locale.ROOT, "%.2f", totalRamGb) + + " GB). Extending boot shield to 90s." + ) 90_000L } else { 45_000L 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 9aa42d6..696aaa8 100644 --- a/app/src/main/java/com/gothwad/launcher/service/LauncherAccessibilityService.kt +++ b/app/src/main/java/com/gothwad/launcher/service/LauncherAccessibilityService.kt @@ -2,17 +2,22 @@ package com.gothwad.launcher.service import android.accessibilityservice.AccessibilityService import android.accessibilityservice.AccessibilityServiceInfo +import android.app.AlarmManager +import android.app.PendingIntent import android.content.Context import android.content.Intent import android.media.AudioManager +import android.os.Build import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.provider.Settings import android.util.Log import android.view.KeyEvent import android.view.accessibility.AccessibilityEvent import com.gothwad.launcher.Actions import com.gothwad.launcher.GothwadApplication +import com.gothwad.launcher.R import com.gothwad.launcher.MainActivity import com.gothwad.launcher.data.BackgroundMediaTracker import com.gothwad.launcher.data.ButtonMappingManager @@ -100,6 +105,19 @@ class LauncherAccessibilityService : AccessibilityService() { @Volatile private var cachedConfig: LauncherConfig = LauncherConfig() + /** + * False until the first config emission arrived. `cachedConfig` starts out as an *empty* + * default, in which no app is locked and nothing is hidden, so enforcing from it would + * fail open: a locked app opened in the first moments after the service binds would go + * straight through. Enforcement is parked until the real config lands (a few ms) instead. + */ + @Volatile + private var configLoaded = false + + /** Foreground package seen while [configLoaded] was still false; re-checked once it flips. */ + @Volatile + private var pendingEnforcementPkg: String? = null + // Overlay state for system-wide app lock private var currentLockOverlay: AppLockOverlayView? = null @Volatile @@ -121,6 +139,17 @@ class LauncherAccessibilityService : AccessibilityService() { store.flow.collectLatest { cfg -> cachedButtonMap = cfg.buttonMap cachedConfig = cfg + val firstLoad = !configLoaded + configLoaded = true + if (firstLoad) { + // Re-run the enforcement check that had to be parked while the config was + // still unknown. + val pkg = pendingEnforcementPkg + pendingEnforcementPkg = null + if (pkg != null) { + mainHandler.post { checkAndEnforceAppLock(pkg) } + } + } } } } @@ -128,6 +157,8 @@ class LauncherAccessibilityService : AccessibilityService() { override fun onDestroy() { stopVolumeRepeat() dismissLockOverlay() + pendingEnforcementPkg = null + configLoaded = false serviceScope.cancel() super.onDestroy() } @@ -186,6 +217,13 @@ class LauncherAccessibilityService : AccessibilityService() { return } + // Fail closed: with no config yet we cannot know what is locked or hidden, so park + // this package and decide as soon as the first emission lands (see [configLoaded]). + if (!configLoaded) { + pendingEnforcementPkg = pkg + return + } + // Hidden apps are a vault: they must not be reachable from another launcher, // Android Settings, notifications or the recents switcher either. We only allow // them once this process has explicitly unlocked them (vault launch). @@ -202,7 +240,7 @@ class LauncherAccessibilityService : AccessibilityService() { pkg = pkg, credential = config.hiddenAppsLock, scope = LockSecurity.SCOPE_VAULT, - title = "Hidden App", + title = getString(R.string.lock_title_hidden), ) } } else { @@ -234,7 +272,7 @@ class LauncherAccessibilityService : AccessibilityService() { pkg = pkg, credential = config.appLock, scope = LockSecurity.SCOPE_APP, - title = "App Locked", + title = getString(R.string.lock_title_app), ) } } @@ -262,7 +300,7 @@ class LauncherAccessibilityService : AccessibilityService() { context = this@LauncherAccessibilityService, credential = credential, title = title, - subtitle = "Enter credential to open $appName", + subtitle = getString(R.string.lock_subtitle_overlay, appName), lockScope = scope, onSuccess = { // Mark package as unlocked for current session @@ -401,8 +439,9 @@ class LauncherAccessibilityService : AccessibilityService() { fun isStockTvLauncher(context: Context, pkg: String): Boolean = StockLauncherDetector.isStockTvLauncher(context, pkg) - fun launchHome(context: Context) { - val intent = Intent(context, MainActivity::class.java).apply { + /** The HOME intent this launcher answers. Shared by every recovery path. */ + fun homeIntent(context: Context): Intent = + Intent(context, MainActivity::class.java).apply { action = Intent.ACTION_MAIN addCategory(Intent.CATEGORY_HOME) flags = Intent.FLAG_ACTIVITY_NEW_TASK or @@ -411,15 +450,63 @@ class LauncherAccessibilityService : AccessibilityService() { Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT } - context.startActivity(intent) + + fun launchHome(context: Context) { + context.startActivity(homeIntent(context)) + } + + /** + * Brings HOME to the front from a *background* component: the `:watchdog` process, the + * boot shield service, or the crash handler. + * + * Android 10+ blocks background activity starts, and it fails **silently** - + * `startActivity` returns normally while the system drops the start, so the old + * `runCatching { launchHome(...) }.isSuccess` reported success and the recovery path did + * nothing. Handing the same intent to AlarmManager makes the system fire it, which is + * one of the documented exemptions; the direct start is kept as the fast path for the + * case where the launcher is already allowed to start activities. + */ + fun launchHomeCompat(context: Context): Boolean { + val intent = homeIntent(context) + + val scheduled = runCatching { + val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager + ?: return@runCatching false + val pending = PendingIntent.getActivity( + context, + HOME_REQUEST_CODE, + intent, + // FLAG_IMMUTABLE only exists from API 23 and minSdk is 21. + PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE + } else { + 0 + }, + ) + am.set( + AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + HOME_ALARM_DELAY_MS, + pending, + ) + true + }.getOrDefault(false) + + val direct = runCatching { + context.startActivity(intent) + true + }.getOrDefault(false) + + if (!scheduled && !direct) { + Log.w(TAG, "Could not bring HOME to the front (background activity start blocked?)") + } + return scheduled || direct } suspend fun launchHomeWithRetry(context: Context, maxAttempts: Int = 5) { var delayMs = 150L for (attempt in 1..maxAttempts) { - val success = runCatching { - launchHome(context) - }.isSuccess + val success = launchHomeCompat(context) if (success) { if (attempt > 1) { android.util.Log.i("LauncherA11y", "launchHome succeeded on retry attempt $attempt") @@ -446,5 +533,9 @@ class LauncherAccessibilityService : AccessibilityService() { /** In-memory set of unlocked packages for current foreground session */ val unlockedPackagesSession: MutableSet = java.util.Collections.synchronizedSet(mutableSetOf()) + + private const val TAG = "LauncherA11y" + private const val HOME_REQUEST_CODE = 4402 + private const val HOME_ALARM_DELAY_MS = 120L } } diff --git a/app/src/main/java/com/gothwad/launcher/service/LauncherWatchdogService.kt b/app/src/main/java/com/gothwad/launcher/service/LauncherWatchdogService.kt index 66e16af..9cd8ddd 100644 --- a/app/src/main/java/com/gothwad/launcher/service/LauncherWatchdogService.kt +++ b/app/src/main/java/com/gothwad/launcher/service/LauncherWatchdogService.kt @@ -117,14 +117,22 @@ class LauncherWatchdogService : Service() { } private fun relaunchLauncher(reason: String) { + // The user turned "launch on boot" off and we are not the default home app: a missing + // heartbeat then means "the launcher simply is not running", not "it crashed", and + // stealing the screen is exactly what the setting is supposed to prevent. + if (!ProcessHeartbeat.isRelaunchAllowed(applicationContext)) { + Log.i(TAG, "Skipping relaunch ($reason): the user disabled launcher auto-start.") + return + } if (!SelfHealGuard.shouldRelaunch(applicationContext)) { Log.w(TAG, "Skipping relaunch ($reason): self-healing is paused after repeated restarts.") return } - runCatching { - LauncherAccessibilityService.launchHome(applicationContext) - }.onFailure { e -> - Log.e(TAG, "Failed to relaunch launcher from watchdog", e) + // launchHomeCompat, not launchHome: this service is a background process, where a plain + // startActivity is dropped silently by Android 10+ background-activity-start rules. + val started = LauncherAccessibilityService.launchHomeCompat(applicationContext) + if (!started) { + Log.e(TAG, "Failed to relaunch launcher from watchdog ($reason)") } } diff --git a/app/src/main/java/com/gothwad/launcher/service/StockLauncherDetector.kt b/app/src/main/java/com/gothwad/launcher/service/StockLauncherDetector.kt index ce354a8..73b9fa8 100644 --- a/app/src/main/java/com/gothwad/launcher/service/StockLauncherDetector.kt +++ b/app/src/main/java/com/gothwad/launcher/service/StockLauncherDetector.kt @@ -84,6 +84,19 @@ object StockLauncherDetector { return packages } + /** + * True when this package is the one Android currently resolves for the HOME intent, i.e. + * the user picked Gothwad Launcher as the default launcher. + * + * Shared by `BootReceiver` and the watchdog: whether it is acceptable to pull the launcher + * to the foreground depends on it. + */ + fun isDefaultHomeApp(context: Context): Boolean = runCatching { + val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME) + context.packageManager.resolveActivity(intent, 0) + ?.activityInfo?.packageName == context.packageName + }.getOrDefault(false) + fun isStockTvLauncher(pkg: String): Boolean { if (isSystemOrSettingsPackage(pkg)) return false return STOCK_LAUNCHERS.any { it.equals(pkg, ignoreCase = true) } diff --git a/app/src/main/java/com/gothwad/launcher/ui/AppLockGate.kt b/app/src/main/java/com/gothwad/launcher/ui/AppLockGate.kt index 4e1d047..cbac993 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/AppLockGate.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/AppLockGate.kt @@ -1,6 +1,9 @@ package com.gothwad.launcher.ui +import android.content.Context +import androidx.annotation.StringRes import androidx.fragment.app.FragmentManager +import com.gothwad.launcher.R import com.gothwad.launcher.data.AppEntry import com.gothwad.launcher.data.LauncherConfig import com.gothwad.launcher.data.LockCredential @@ -36,10 +39,13 @@ object AppLockGate { * Evaluates the locks for [app] and shows the credential prompt when one is needed. * Only App Lock and Hidden App Vault Lock are evaluated. * + * @param context used to resolve prompt strings (kept explicit so this object never has + * to reach for a global application context). * @param onProceed invoked on the calling (main) thread once the app may be launched - * either immediately, or after the user entered the correct credential. */ fun evaluate( + context: Context, fragmentManager: FragmentManager, app: AppEntry, config: LauncherConfig, @@ -52,12 +58,13 @@ object AppLockGate { if (needsAppUnlock) { prompt( + context = context, fragmentManager = fragmentManager, app = app, credential = config.appLock, scope = LockSecurity.SCOPE_APP, - title = "App Locked", - subtitlePrefix = "Enter PIN/Password to launch", + titleRes = R.string.lock_title_app, + subtitleRes = R.string.lock_subtitle_app, onProceed = onProceed, ) return Result.PROMPTED @@ -70,12 +77,13 @@ object AppLockGate { if (needsVaultUnlock) { prompt( + context = context, fragmentManager = fragmentManager, app = app, credential = config.hiddenAppsLock, scope = LockSecurity.SCOPE_VAULT, - title = "Hidden App", - subtitlePrefix = "Enter vault credential to launch", + titleRes = R.string.lock_title_hidden, + subtitleRes = R.string.lock_subtitle_hidden, onProceed = onProceed, ) return Result.PROMPTED @@ -90,17 +98,18 @@ object AppLockGate { } private fun prompt( + context: Context, fragmentManager: FragmentManager, app: AppEntry, credential: LockCredential, scope: String, - title: String, - subtitlePrefix: String, + @StringRes titleRes: Int, + @StringRes subtitleRes: Int, onProceed: () -> Unit, ) { PinEntryDialogFragment.newInstance( - title = title, - subtitle = "$subtitlePrefix ${app.label}", + title = context.getString(titleRes), + subtitle = context.getString(subtitleRes, app.label), credential = credential, isCancelable = true, lockScope = scope, 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 af7d950..b3b11e1 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/DensityAdapter.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/DensityAdapter.kt @@ -48,6 +48,67 @@ object DensityAdapter { */ @Volatile var respectSystemFontScale: Boolean = true + private set + + /** + * Whole-UI multiplier from Settings > Display > UI Scale (`UI_SCALES` in Config.kt). + * + * It is applied to the *calculated density*: 1.3x makes every dp physically larger, so + * the design canvas shrinks from 960dp to ~738dp wide and the whole launcher scales up. + * This used to be a settings toggle that was persisted and labelled but never actually + * applied to anything - the UI looked identical at 0.75x and 1.3x. + */ + @Volatile + var userUiScale: Float = 1.0f + private set + + /** Persisted so the first frame after a cold start already uses the user's choices. */ + private const val PREFS_NAME = "gothwad_density" + private const val KEY_UI_SCALE = "ui_scale" + private const val KEY_RESPECT_FONT_SCALE = "respect_system_font_scale" + + /** Guard rails so a corrupt preference can never render the launcher unusable. */ + const val MIN_UI_SCALE = 0.6f + const val MAX_UI_SCALE = 1.6f + + private fun prefs(context: Context) = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + /** + * Loads the persisted scale / font policy. Cheap after the first call (SharedPreferences + * keeps the parsed map in memory); called from every entry point below so a value written + * by another component is never ignored. + */ + fun hydrate(context: Context) { + val sp = prefs(context) + userUiScale = sp.getFloat(KEY_UI_SCALE, 1.0f).coerceIn(MIN_UI_SCALE, MAX_UI_SCALE) + respectSystemFontScale = sp.getBoolean(KEY_RESPECT_FONT_SCALE, true) + } + + /** + * Persists + applies a new whole-UI scale. + * + * NOTE: views that are already inflated keep their old pixel sizes, so the caller must + * recreate the activity for the change to become visible end-to-end. + * + * @return true when the effective value actually changed. + */ + 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 + userUiScale = clamped + prefs(context).edit().putFloat(KEY_UI_SCALE, clamped).apply() + Log.i(TAG, "UI scale set to ${clamped}x") + return changed + } + + /** Persists + applies the "respect system font size" policy. */ + fun setRespectSystemFontScale(context: Context, enabled: Boolean): Boolean { + val changed = respectSystemFontScale != enabled + respectSystemFontScale = enabled + prefs(context).edit().putBoolean(KEY_RESPECT_FONT_SCALE, enabled).apply() + return changed + } /** System font scale, clamped so a broken ROM value cannot render the UI unusable. */ private fun effectiveFontScale(): Float = @@ -101,7 +162,11 @@ object DensityAdapter { */ fun calculateMetrics(context: Context): AdaptedMetrics { val (screenWidthPx, screenHeightPx) = getRealScreenSize(context) - val targetDensity = (screenWidthPx.toFloat() / DESIGN_WIDTH_DP).coerceAtLeast(0.5f) + val baseline = (screenWidthPx.toFloat() / DESIGN_WIDTH_DP).coerceAtLeast(0.5f) + // UI Scale multiplies the baseline density: >1 = bigger everything (fewer dp across + // the screen), <1 = more compact. + val scale = userUiScale.coerceIn(MIN_UI_SCALE, MAX_UI_SCALE) + val targetDensity = (baseline * scale).coerceAtLeast(0.4f) val targetDensityDpi = (targetDensity * 160f).roundToInt() val screenWidthDp = (screenWidthPx / targetDensity).roundToInt() val screenHeightDp = (screenHeightPx / targetDensity).roundToInt() @@ -145,6 +210,7 @@ object DensityAdapter { * Ideal for [Activity.attachBaseContext]. */ fun wrapContext(baseContext: Context): Context { + hydrate(baseContext) val metrics = calculateMetrics(baseContext) val config = Configuration(baseContext.resources.configuration).apply { densityDpi = metrics.targetDensityDpi @@ -163,6 +229,7 @@ object DensityAdapter { * Enforces the target DPI on the provided [Context] (Activity or Application). */ fun apply(context: Context) { + hydrate(context) val metrics = calculateMetrics(context) applyToResources(context.resources, metrics) } @@ -171,6 +238,7 @@ object DensityAdapter { * Enforces the target DPI on an [Activity], its Window decor, and Application resources. */ fun apply(activity: Activity) { + hydrate(activity) val metrics = calculateMetrics(activity) applyToResources(activity.resources, metrics) activity.window?.decorView?.resources?.let { applyToResources(it, metrics) } @@ -185,6 +253,7 @@ object DensityAdapter { fun init(application: Application) { if (isInitialized) return isInitialized = true + hydrate(application) val metrics = calculateMetrics(application) applyToResources(application.resources, metrics) 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 34ef177..60960ed 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/MainStatusBarController.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/MainStatusBarController.kt @@ -16,12 +16,15 @@ import com.gothwad.launcher.data.ConfigStore 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.bluetoothStatusFlow import com.gothwad.launcher.data.networkStatusFlow import com.gothwad.launcher.databinding.ActivityMainBinding import com.gothwad.launcher.service.LauncherAccessibilityService import com.gothwad.launcher.service.NotificationManagerBridge +import com.gothwad.launcher.service.StockLauncherDetector import com.gothwad.launcher.ui.dialogs.NotificationBottomSheetFragment +import com.gothwad.launcher.ui.dialogs.QuickDashboardDialogFragment import com.gothwad.launcher.ui.dialogs.SearchDialogFragment import com.gothwad.launcher.ui.dialogs.SettingsBottomSheetFragment import com.gothwad.launcher.ui.dialogs.SetupWizardDialogFragment @@ -97,9 +100,30 @@ class MainStatusBarController( onSettingsClick = { openSettingsDialog() } + + onDashboardClick = { + showQuickDashboard() + } } } + /** + * One-panel overview: network, Bluetooth remote and volume. + * + * The panel already existed but had no entry point anywhere in the UI, so the feature the + * README advertises was unreachable. The clock cluster opens it. + */ + private fun showQuickDashboard() { + // Bluetooth details (remote name, battery) need BLUETOOTH_CONNECT on Android 12+; + // ask now, at the moment the user actually looks at that data. + activity.ensureBluetoothPermission() + QuickDashboardDialogFragment.newInstance( + net = currentNetStatus, + bt = currentBtStatus, + onOpenSettings = { openSettingsDialog() }, + ).show(activity.supportFragmentManager, QuickDashboardDialogFragment.TAG) + } + fun updateA11yStatus() { val isA11yEnabled = LauncherAccessibilityService.isEnabled(activity) binding.mainStatusBar.setA11yAlertVisible(!isA11yEnabled) @@ -129,6 +153,41 @@ class MainStatusBarController( }.show(activity.supportFragmentManager, SetupWizardDialogFragment.TAG) } + /** + * Pushes the persisted UI-scale + font-scale policy into [DensityAdapter]. + * + * `LauncherConfig` is the source of truth; the adapter keeps its own tiny prefs file so + * the very first frame after a cold start is already correct. When the two disagree the + * activity is recreated once, because density is baked into views at inflate time and + * already-inflated views would otherwise keep their old pixel sizes. + */ + private fun applyDensityPolicy(config: LauncherConfig) { + val scale = UI_SCALES.getOrElse(config.uiScale.coerceIn(0, UI_SCALES.size - 1)) { 1.0f } + val scaleChanged = DensityAdapter.setUiScale(activity, scale) + val fontChanged = DensityAdapter.setRespectSystemFontScale( + activity, + config.respectSystemFontScale, + ) + if (!scaleChanged && !fontChanged) return + runCatching { DensityAdapter.apply(activity) } + if (scaleChanged) { + runCatching { activity.recreate() } + } + } + + /** + * Tells the `:watchdog` process whether it may pull the launcher to the foreground. + * + * The watchdog cannot read the DataStore itself (Preferences DataStore is single-process and + * two processes on one file can corrupt it), so the policy is mirrored into a tiny file in + * device-protected storage - see [ProcessHeartbeat.setRelaunchAllowed]. + */ + private fun publishWatchdogPolicy(config: LauncherConfig) { + val allowed = config.launchOnBoot || + StockLauncherDetector.isDefaultHomeApp(activity) + ProcessHeartbeat.setRelaunchAllowed(activity, allowed) + } + private fun observeStatusBarData() { val configStore = ConfigStore(activity) @@ -141,11 +200,8 @@ class MainStatusBarController( com.gothwad.launcher.data.AppLaunchTracker.aggressiveTrimEnabled = config.aggressiveMemoryTrim - if (com.gothwad.launcher.ui.DensityAdapter.respectSystemFontScale != config.respectSystemFontScale) { - com.gothwad.launcher.ui.DensityAdapter.respectSystemFontScale = - config.respectSystemFontScale - runCatching { com.gothwad.launcher.ui.DensityAdapter.apply(activity) } - } + applyDensityPolicy(config) + publishWatchdogPolicy(config) binding.mainStatusBar.applyConfig(config) binding.mainStatusBar.visibility = 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 d03b640..ff7f306 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/Theme.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/Theme.kt @@ -9,16 +9,31 @@ val ACCENTS: List = listOf( 0xFF78D9EC.toInt(), ) -data class WallpaperPreset(val name: String, val colors: List) +/** + * 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). + */ +data class WallpaperPreset(val name: String, val emoji: String, val colors: List) { + /** Label shown in pickers, e.g. "✨ Aurora". */ + fun displayLabel(): String = if (emoji.isEmpty()) name else "$emoji $name" +} val WALLPAPERS: List = listOf( - WallpaperPreset("Midnight", 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("Midnight", "🌌", 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())), ) +/** Preset used on a fresh install (matches `LauncherConfig.wallpaper`'s default). */ +const val DEFAULT_WALLPAPER_INDEX = 1 + /** Deterministic tile color for apps that ship no banner artwork. */ fun tileColor(pkg: String): Int { val palette = listOf( diff --git a/app/src/main/java/com/gothwad/launcher/ui/dialogs/QuickDashboardDialogFragment.kt b/app/src/main/java/com/gothwad/launcher/ui/dialogs/QuickDashboardDialogFragment.kt index 5d80fe5..a948864 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/dialogs/QuickDashboardDialogFragment.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/dialogs/QuickDashboardDialogFragment.kt @@ -9,6 +9,7 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.DialogFragment import com.gothwad.launcher.Actions +import com.gothwad.launcher.R import com.gothwad.launcher.data.BluetoothDeviceStatus import com.gothwad.launcher.data.NetStatus import com.gothwad.launcher.data.TvControlHelper @@ -73,8 +74,18 @@ class QuickDashboardDialogFragment : DialogFragment() { val netIconPath = if (netStatus.wifi) AppIcons.PATH_WIFI else if (netStatus.ethernet) AppIcons.PATH_ETHERNET else AppIcons.PATH_WIFI_OFF val netColor = if (isNetConnected) 0xFF81C784.toInt() else 0xFFE57373.toInt() binding.imgNetIcon.setImageDrawable(AppIcons.createDrawable(netIconPath, netColor)) - binding.tvNetTitle.text = if (netStatus.wifi) "Wi-Fi" else if (netStatus.ethernet) "Ethernet" else "Offline" - binding.tvNetSub.text = if (netStatus.ssid.isNotEmpty()) netStatus.ssid else if (isNetConnected) "Connected" else "Disconnected" + binding.tvNetTitle.text = getString( + when { + netStatus.wifi -> R.string.dashboard_net_wifi + netStatus.ethernet -> R.string.dashboard_net_ethernet + else -> R.string.dashboard_net_offline + } + ) + binding.tvNetSub.text = when { + netStatus.ssid.isNotEmpty() -> netStatus.ssid + isNetConnected -> getString(R.string.dashboard_net_connected) + else -> getString(R.string.dashboard_net_disconnected) + } binding.tileNetwork.setOnClickListener { Actions.openNetworkSettings(requireContext()) @@ -83,8 +94,8 @@ class QuickDashboardDialogFragment : DialogFragment() { // Home action binding.imgHomeIcon.setImageDrawable(AppIcons.createDrawable(AppIcons.PATH_HOME, 0xFF64B5F6.toInt())) - binding.tvHomeTitle.text = "Home" - binding.tvHomeSub.text = "Return Home" + binding.tvHomeTitle.text = getString(R.string.dashboard_home_title) + binding.tvHomeSub.text = getString(R.string.dashboard_home_subtitle) binding.tileHome.setOnClickListener { dismiss() diff --git a/app/src/main/java/com/gothwad/launcher/ui/dialogs/SearchDialogFragment.kt b/app/src/main/java/com/gothwad/launcher/ui/dialogs/SearchDialogFragment.kt index 8b9fad8..3376033 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/dialogs/SearchDialogFragment.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/dialogs/SearchDialogFragment.kt @@ -9,6 +9,7 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.DialogFragment import androidx.recyclerview.widget.LinearLayoutManager +import com.gothwad.launcher.R import com.gothwad.launcher.data.AppEntry import com.gothwad.launcher.data.LauncherConfig import com.gothwad.launcher.data.LockSecurity @@ -91,9 +92,10 @@ class SearchDialogFragment : DialogFragment() { private fun updateAppList(query: String) { val trimmed = query.trim() - val isSecretMatch = if (trimmed.isEmpty()) false else { - config.hiddenAppsRevealCode.isNotEmpty() && trimmed.equals(config.hiddenAppsRevealCode, ignoreCase = true) - } + // Hashed comparison (see LockSecurity): the reveal code is no longer stored - or + // compared - as plain text. + val isSecretMatch = trimmed.isNotEmpty() && + LockSecurity.verifyRevealCode(config, trimmed) if (isSecretMatch) { // Phase 5: Distinct second-layer confirmation before hidden apps actually render and launch @@ -117,7 +119,7 @@ class SearchDialogFragment : DialogFragment() { adapter?.submitList(visibleApps) binding.tvEmptyState.visibility = if (visibleApps.isEmpty()) View.VISIBLE else View.GONE if (visibleApps.isEmpty()) { - binding.tvEmptyState.text = "No apps found matching '$trimmed'" + binding.tvEmptyState.text = getString(R.string.search_empty_state, trimmed) } } } @@ -136,8 +138,8 @@ class SearchDialogFragment : DialogFragment() { // ("already executing transactions") and was only needed because the cancel path // had no callback. PinEntryDialogFragment reports cancellation directly. PinEntryDialogFragment.newInstance( - title = "Hidden Apps Vault", - subtitle = "Enter second-layer credential to access hidden apps", + title = getString(R.string.lock_vault_confirm_title), + subtitle = getString(R.string.lock_vault_confirm_subtitle), credential = config.hiddenAppsLock, isCancelable = true, lockScope = LockSecurity.SCOPE_VAULT, @@ -162,7 +164,7 @@ class SearchDialogFragment : DialogFragment() { adapter?.submitList(hiddenApps) binding.tvEmptyState.visibility = if (hiddenApps.isEmpty()) View.VISIBLE else View.GONE if (hiddenApps.isEmpty()) { - binding.tvEmptyState.text = "No apps are currently hidden" + binding.tvEmptyState.text = getString(R.string.search_vault_empty) } } 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 32dad01..2208cb2 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 @@ -209,63 +209,63 @@ class SettingsBottomSheetFragment : DialogFragment() { } private fun setupRootMenuClicks() { - binding.rowCatAppearance.setOnClickListener { navigateToSubPage(binding.pageCatAppearance, "Appearance & Themes") } - binding.rowCatApps.setOnClickListener { navigateToSubPage(binding.pageCatApps, "Apps & Security") } - binding.rowCatRemote.setOnClickListener { navigateToSubPage(binding.subpageButtonMapping, "Remote Button Mapping") } - binding.rowCatDevice.setOnClickListener { navigateToSubPage(binding.pageCatDevice, "Device, Network & System") } + binding.rowCatAppearance.setOnClickListener { navigateToSubPage(binding.pageCatAppearance, getString(R.string.page_title_appearance)) } + binding.rowCatApps.setOnClickListener { navigateToSubPage(binding.pageCatApps, getString(R.string.page_title_apps_security)) } + binding.rowCatRemote.setOnClickListener { navigateToSubPage(binding.subpageButtonMapping, getString(R.string.button_mapping_title)) } + binding.rowCatDevice.setOnClickListener { navigateToSubPage(binding.pageCatDevice, getString(R.string.page_title_device)) } // Sub-page clicks within categories - binding.rowWallpaper.setOnClickListener { navigateToSubPage(binding.pageWallpaper, "Wallpaper & Themes") } - binding.rowDisplay.setOnClickListener { navigateToSubPage(binding.pageDisplay, "Display & App Curves") } - binding.rowStatusbar.setOnClickListener { navigateToSubPage(binding.pageStatusbar, "Status Bar & Clock") } - binding.rowApps.setOnClickListener { navigateToSubPage(binding.pageApps, "Apps & Secret Vault") } - binding.rowSecurity.setOnClickListener { navigateToSubPage(binding.pageSecurity, "Security & PIN Locks") } + binding.rowWallpaper.setOnClickListener { navigateToSubPage(binding.pageWallpaper, getString(R.string.page_title_wallpaper)) } + 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)) } + binding.rowSecurity.setOnClickListener { navigateToSubPage(binding.pageSecurity, getString(R.string.page_title_security)) } binding.rowNetwork.setOnClickListener { Actions.openNetworkSettings(requireContext()) } binding.rowDevicePrefs.setOnClickListener { safeStartActivity(Settings.ACTION_SETTINGS) } binding.rowPermissions.setOnClickListener { Actions.openAppInfo(requireContext(), requireContext().packageName) } - binding.rowCatAbout.setOnClickListener { navigateToSubPage(binding.pageAbout, "System & About") } + binding.rowCatAbout.setOnClickListener { navigateToSubPage(binding.pageAbout, getString(R.string.page_title_about)) } } private fun setupFocusDescriptions() { binding.rowCatAppearance.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) binding.txtHeroSubtitle.text = "Customize wallpaper, live TV banner curves, UI scale, and status bar clock." + if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_cat_appearance) } binding.rowCatApps.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) binding.txtHeroSubtitle.text = "Manage installed apps, hide sensitive apps into secret vault, and set PIN locks." + if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_cat_apps) } binding.rowCatRemote.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) binding.txtHeroSubtitle.text = "Map dedicated TV remote hotkeys to directly launch your favorite apps." + if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_cat_remote) } binding.rowCatDevice.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) binding.txtHeroSubtitle.text = "Manage Wi-Fi, Android TV settings, permissions, setup wizard, and app info." + if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_cat_device) } binding.rowWallpaper.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) binding.txtHeroSubtitle.text = "Choose dynamic gradient themes, solid colors, or set a personal custom wallpaper." + if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_wallpaper) } binding.rowDisplay.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) binding.txtHeroSubtitle.text = "Adjust corner rounding and UI layout scale for your TV screen." + if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_display) } binding.rowStatusbar.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) binding.txtHeroSubtitle.text = "Configure digital clock format, glass styling, and status bar appearance." + if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_statusbar) } binding.rowApps.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) binding.txtHeroSubtitle.text = "Manage installed apps, hide sensitive apps into a PIN-protected vault, and set sorting." + if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_apps) } binding.rowSecurity.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) binding.txtHeroSubtitle.text = "Protect apps and vault settings with secure PIN or pattern locks." + if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_security) } binding.rowNetwork.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) binding.txtHeroSubtitle.text = "Configure Wi-Fi, Ethernet, and network proxy connections." + if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_network) } binding.rowDevicePrefs.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) binding.txtHeroSubtitle.text = "Open full device Android system preferences, display, audio, and accounts." + if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_device_prefs) } binding.rowPermissions.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) binding.txtHeroSubtitle.text = "Review and manage system permissions, overlay rights, and storage access." + if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_permissions) } binding.rowCatAbout.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) binding.txtHeroSubtitle.text = "Rerun setup wizard, set default launcher, restart, and view app details." + if (hasFocus) binding.txtHeroSubtitle.setText(R.string.focus_desc_about) } } @@ -326,23 +326,41 @@ class SettingsBottomSheetFragment : DialogFragment() { } } + /** + * Shows which install path this device supports. + * + * The probe shells out to `su`, which can block for as long as a root prompt waits for the + * user, so it runs on IO behind a "checking" state instead of on the UI thread - calling it + * synchronously here was an ANR on any rooted box. + */ private fun updateAdbStatusBadge() { if (!isAdded || _binding == null) return - val hasShell = AppUpdateManager.hasRootOrAdbShell() - val canPkgInstall = AppUpdateManager.canRequestPackageInstalls(requireContext()) - - if (hasShell) { - binding.txtAdbStatusBadge.text = "⚡ Root / ADB Shell: Available (Silent Update)" - binding.txtAdbStatusBadge.setTextColor(Color.parseColor("#81C784")) - binding.btnGrantInstallPerm.visibility = View.GONE - } else if (canPkgInstall) { - binding.txtAdbStatusBadge.text = "📦 Package Installer: Allowed (ADB/Direct shell fallback)" - binding.txtAdbStatusBadge.setTextColor(Color.parseColor("#64B5F6")) - binding.btnGrantInstallPerm.visibility = View.GONE - } else { - binding.txtAdbStatusBadge.text = "⚠️ Install Permission: Needed for stock TV installer" - binding.txtAdbStatusBadge.setTextColor(Color.parseColor("#FFB74D")) - binding.btnGrantInstallPerm.visibility = View.VISIBLE + val context = requireContext().applicationContext + + binding.txtAdbStatusBadge.setText(R.string.update_badge_checking) + binding.txtAdbStatusBadge.setTextColor(Color.parseColor("#94A3B8")) + + viewLifecycleOwner.lifecycleScope.launch { + val capability = AppUpdateManager.detectInstallCapability(context) + if (!isAdded || _binding == null) return@launch + + when (capability) { + AppUpdateManager.InstallCapability.ROOT_SHELL -> { + binding.txtAdbStatusBadge.setText(R.string.update_badge_root) + binding.txtAdbStatusBadge.setTextColor(Color.parseColor("#81C784")) + binding.btnGrantInstallPerm.visibility = View.GONE + } + AppUpdateManager.InstallCapability.PACKAGE_INSTALLER -> { + binding.txtAdbStatusBadge.setText(R.string.update_badge_installer) + binding.txtAdbStatusBadge.setTextColor(Color.parseColor("#64B5F6")) + binding.btnGrantInstallPerm.visibility = View.GONE + } + AppUpdateManager.InstallCapability.NEEDS_PERMISSION -> { + binding.txtAdbStatusBadge.setText(R.string.update_badge_permission) + binding.txtAdbStatusBadge.setTextColor(Color.parseColor("#FFB74D")) + binding.btnGrantInstallPerm.visibility = View.VISIBLE + } + } } } @@ -546,8 +564,8 @@ class SettingsBottomSheetFragment : DialogFragment() { binding.layoutRootMenu.visibility = View.VISIBLE binding.btnBack.visibility = View.GONE - binding.txtSettingsTitle.text = "Settings" - binding.txtHeroSubtitle.text = "Customize launcher display, apps, locks, and system preferences" + binding.txtSettingsTitle.setText(R.string.settings_root_title) + binding.txtHeroSubtitle.setText(R.string.settings_root_subtitle) binding.scrollContainer.smoothScrollTo(0, 0) } @@ -559,10 +577,10 @@ class SettingsBottomSheetFragment : DialogFragment() { try { startActivity(Intent(fallbackAction)) } catch (_: Exception) { - Actions.toast(requireContext(), "Cannot open system settings on this device") + Actions.toast(requireContext(), getString(R.string.toast_cannot_open_settings)) } } else { - Actions.toast(requireContext(), "Cannot open system settings on this device") + Actions.toast(requireContext(), getString(R.string.toast_cannot_open_settings)) } } } 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 84f3d54..411e701 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 @@ -2,9 +2,12 @@ package com.gothwad.launcher.ui.dialogs import android.graphics.Color import androidx.lifecycle.lifecycleScope +import com.gothwad.launcher.Actions +import com.gothwad.launcher.R import com.gothwad.launcher.data.CORNER_RADII import com.gothwad.launcher.data.ConfigStore import com.gothwad.launcher.data.LauncherConfig +import com.gothwad.launcher.data.UI_SCALES import com.gothwad.launcher.databinding.SheetSettingsBinding import com.gothwad.launcher.ui.DensityAdapter import kotlinx.coroutines.launch @@ -93,6 +96,19 @@ class SettingsDisplayDelegate( updateConfig(newConfig) highlightSelectedScale(scaleIdx) updateSubtitles() + + // Actually apply it. Persisting alone used to leave this control a no-op: + // density is baked into every view at inflate time, so the host activity + // has to be recreated for the new scale to reach the whole UI. The sheet + // dismisses itself on recreation (see SettingsBottomSheetFragment). + val multiplier = UI_SCALES.getOrElse(scaleIdx) { 1.0f } + val context = fragment.requireContext() + if (DensityAdapter.setUiScale(context, multiplier)) { + runCatching { DensityAdapter.apply(context) } + val activity = fragment.requireActivity() + Actions.toast(context, context.getString(R.string.toast_ui_scale_applied)) + activity.recreate() + } } } } @@ -105,7 +121,7 @@ class SettingsDisplayDelegate( store.update { it.copy(respectSystemFontScale = newVal) } val newConfig = getConfig().copy(respectSystemFontScale = newVal) updateConfig(newConfig) - DensityAdapter.respectSystemFontScale = newVal + DensityAdapter.setRespectSystemFontScale(fragment.requireContext(), newVal) runCatching { DensityAdapter.apply(fragment.requireContext()) } updateSubtitles() } 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 c8c5e60..0c6d4b4 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 @@ -3,10 +3,13 @@ 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 { @@ -33,22 +36,24 @@ object SettingsMenuHelper { } fun updateSubtitles(binding: SheetSettingsBinding, config: LauncherConfig, context: Context) { - val wpName = if (config.useCustomWallpaper) "Custom Image" else when (config.wallpaper) { - 0 -> "Deep Space" - 1 -> "Aurora" - 2 -> "Cyberpunk" - 3 -> "Sunset" - 4 -> "Obsidian" - else -> "Preset ${config.wallpaper}" - } - val scrimName = when (config.scrimMode) { - 0 -> "Full Scrim" - 1 -> "Top Only" - 2 -> "Bottom Only" - 3 -> "Both" - 4 -> "None" - else -> "Normal" + // Read the name from the preset that is actually rendered - this list used to be a + // second, stale copy of the wallpaper names. + val wpName = if (config.useCustomWallpaper) { + context.getString(R.string.wallpaper_custom) + } else { + WALLPAPERS.getOrElse(config.wallpaper.coerceIn(0, WALLPAPERS.size - 1)) { + WALLPAPERS.first() + }.name } + val scrimName = context.getString( + when (config.scrimMode) { + 1 -> R.string.scrim_top_only + 2 -> R.string.scrim_bottom_only + 3 -> R.string.scrim_both_edges + 4 -> R.string.scrim_none + else -> R.string.scrim_full + } + ) binding.txtWallpaperSubtitle.text = "$wpName • $scrimName" val uiScaleName = UI_SCALES.getOrElse(config.uiScale.coerceIn(0, UI_SCALES.size - 1)) { 1.0f } @@ -105,6 +110,30 @@ object SettingsMenuHelper { } else { "Wi-Fi, Android TV settings, launcher info & wizard" } - binding.txtCatAboutSubtitle.text = "v1.0.6 (Build 7) • Setup wizard" + // Was hard-coded as "v1.0.6 (Build 7)", which went stale the moment CI bumped the + // version (the app was reporting 1.0.6 while running 1.0.2x). + val (versionName, versionCode) = appVersion(context) + binding.txtCatAboutSubtitle.text = context.getString( + R.string.settings_about_subtitle, + versionName, + versionCode.toString(), + ) } + + /** + * Version name + code of the running APK, read from the PackageManager. + * + * `BuildConfig` is not generated in this project (AGP 8 leaves `buildFeatures.buildConfig` + * off), so the package info is the only source that cannot go stale. + */ + private fun appVersion(context: Context): Pair = runCatching { + val info = context.packageManager.getPackageInfo(context.packageName, 0) + val code = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + info.longVersionCode.toInt() + } else { + @Suppress("DEPRECATION") + info.versionCode + } + (info.versionName ?: "?") to code + }.getOrDefault("?" to 0) } diff --git a/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsSecurityDelegate.kt b/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsSecurityDelegate.kt index 4827ac8..57111b6 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsSecurityDelegate.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/dialogs/SettingsSecurityDelegate.kt @@ -253,20 +253,29 @@ class SettingsSecurityDelegate( } fun updateRevealCodeLabel() { - val code = getConfig().hiddenAppsRevealCode - binding.txtHiddenRevealCodeValue.text = if (code.isNotEmpty()) { - "Active: •••••• (type the code in search… click to change)" - } else { - "Not set — click to set reveal code" - } + val context = fragment.requireContext() + binding.txtHiddenRevealCodeValue.text = context.getString( + if (LockSecurity.isRevealCodeSet(getConfig())) { + R.string.reveal_code_active + } else { + R.string.reveal_code_not_set + } + ) } private fun showEditRevealCodeDialog() { val context = fragment.requireContext() val dialogView = LayoutInflater.from(context).inflate(R.layout.dialog_edit_text, null) val dialogBinding = DialogEditTextBinding.bind(dialogView) - dialogBinding.etRevealCode.setText(getConfig().hiddenAppsRevealCode) - dialogBinding.etRevealCode.setSelection(dialogBinding.etRevealCode.text.length) + // Nothing to prefill: only a salted hash of the code is stored, and it cannot be + // reversed. Say so instead of showing an empty field that looks unset. + dialogBinding.txtRevealCodeDesc.setText( + if (LockSecurity.isRevealCodeSet(getConfig())) { + R.string.reveal_code_desc_change + } else { + R.string.reveal_code_desc_new + } + ) val dialog = AlertDialog.Builder(context, R.style.Theme_LiteTV_Dialog) .setView(dialogView) @@ -277,13 +286,19 @@ class SettingsSecurityDelegate( } dialogBinding.btnSaveRevealCode.setOnClickListener { - val code = dialogBinding.etRevealCode.text.toString().trim() + val code = dialogBinding.etRevealCode.text.toString() if (!fragment.isAdded) return@setOnClickListener fragment.viewLifecycleOwner.lifecycleScope.launch { - store.update { it.copy(hiddenAppsRevealCode = code) } - updateConfig(getConfig().copy(hiddenAppsRevealCode = code)) + store.update { LockSecurity.applyRevealCode(it, code) } + updateConfig(LockSecurity.applyRevealCode(getConfig(), code)) updateRevealCodeLabel() - Actions.toast(context, if (code.isNotEmpty()) "Reveal code saved" else "Reveal code cleared") + Actions.toast( + context, + context.getString( + if (code.isNotBlank()) R.string.reveal_code_saved + else R.string.reveal_code_cleared + ), + ) dialog.dismiss() } } 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 55a94fd..d0dba34 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,9 +1,12 @@ package com.gothwad.launcher.ui.dialogs import androidx.lifecycle.lifecycleScope +import com.gothwad.launcher.R import com.gothwad.launcher.data.ConfigStore import com.gothwad.launcher.data.LauncherConfig import com.gothwad.launcher.databinding.SheetSettingsBinding +import com.gothwad.launcher.ui.DEFAULT_WALLPAPER_INDEX +import com.gothwad.launcher.ui.WALLPAPERS import kotlinx.coroutines.launch class SettingsWallpaperDelegate( @@ -26,7 +29,19 @@ class SettingsWallpaperDelegate( binding.btnWp4 to 4 ) + // 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. + 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()) + } else { + preset.displayLabel() + } + } btn.setOnClickListener { fragment.viewLifecycleOwner.lifecycleScope.launch { store.update { 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 121de04..c303b16 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 @@ -1,6 +1,7 @@ package com.gothwad.launcher.ui.tv import android.content.Intent +import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Rect import android.graphics.drawable.GradientDrawable @@ -26,6 +27,8 @@ import com.gothwad.launcher.data.LauncherConfig import com.gothwad.launcher.databinding.FragmentTvLauncherBinding import com.gothwad.launcher.ui.ACCENTS 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.SetupWizardDialogFragment import com.gothwad.launcher.ui.view.AppCardAdapter @@ -52,6 +55,17 @@ class TvLauncherFragment : Fragment() { /** Guards against re-showing the first-run wizard on every config emission. */ private var wizardShownThisView: Boolean = false + /** False until the first package scan completed, so early config emissions cannot blank the grid. */ + private var hasLoadedApps: Boolean = false + + /** + * Identifies the wallpaper currently drawn ("preset:" or + * "custom::"). The config flow re-emits on every settings change; without + * this guard each emission re-decoded the custom wallpaper and left the previous bitmap to + * the GC - an easy OOM on a 2 GB TV box. + */ + private var appliedWallpaperKey: String? = null + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -129,12 +143,14 @@ class TvLauncherFragment : Fragment() { private fun handleAppLaunch(app: AppEntry) { // App lock & hidden vault gate + val context = requireContext() AppLockGate.evaluate( + context = context, fragmentManager = parentFragmentManager, app = app, config = currentConfig, ) { - Actions.launchApp(requireContext(), app.pkg) + Actions.launchApp(context, app.pkg) } } @@ -183,54 +199,90 @@ class TvLauncherFragment : Fragment() { } /** - * Applies the configured wallpaper. + * Applies the configured wallpaper and scrim. + * + * Cheap and idempotent: the bitmap is only re-decoded when the source actually changed + * (see [appliedWallpaperKey]), and it is decoded down-sampled to the physical screen size + * instead of at the camera's full resolution. */ fun applyWallpaper() { val viewBinding = _binding ?: return if (!isAdded) return + val appContext = requireContext().applicationContext + val config = currentConfig + viewLifecycleOwner.lifecycleScope.launch { - if (currentConfig.useCustomWallpaper) { - val file = File(requireContext().filesDir, "wallpaper.jpg") - if (file.exists()) { + val customFile = File(appContext.filesDir, WALLPAPER_FILE) + val wantsCustom = config.useCustomWallpaper && customFile.exists() + val key = if (wantsCustom) { + "custom:${customFile.length()}:${customFile.lastModified()}" + } else { + "preset:${config.wallpaper}" + } + + if (key != appliedWallpaperKey) { + if (wantsCustom) { + val (screenW, screenH) = DensityAdapter.getRealScreenSize(appContext) val bmp = withContext(Dispatchers.IO) { - BitmapFactory.decodeFile(file.absolutePath) + decodeSampledWallpaper(customFile, screenW, screenH) } if (bmp != null) { viewBinding.imgWallpaper.setImageBitmap(bmp) viewBinding.imgWallpaper.visibility = View.VISIBLE + appliedWallpaperKey = key } + } else { + // Also the fallback when a custom wallpaper is configured but its file is + // gone (cache clear / failed pick) - better a preset than a black screen. + val preset = WALLPAPERS.getOrElse( + config.wallpaper.coerceIn(0, WALLPAPERS.size - 1) + ) { WALLPAPERS[DEFAULT_WALLPAPER_INDEX] } + viewBinding.imgWallpaper.setImageDrawable( + GradientDrawable( + GradientDrawable.Orientation.TL_BR, + preset.colors.toIntArray(), + ) + ) + viewBinding.imgWallpaper.visibility = View.VISIBLE + appliedWallpaperKey = key } - } else { - val preset = WALLPAPERS.getOrElse(currentConfig.wallpaper.coerceIn(0, WALLPAPERS.size - 1)) { WALLPAPERS[0] } - val colors = preset.colors.toIntArray() - - val gradient = GradientDrawable(GradientDrawable.Orientation.TL_BR, colors) - viewBinding.imgWallpaper.setImageDrawable(gradient) - viewBinding.imgWallpaper.visibility = View.VISIBLE } - // Scrim mode: 0 = top & bottom, 1 = top, 2 = bottom, 3 = both, 4 = off - when (currentConfig.scrimMode) { - 0, 3 -> { - viewBinding.viewScrimTop.visibility = View.VISIBLE - viewBinding.viewScrimBottom.visibility = View.VISIBLE - } - 1 -> { - viewBinding.viewScrimTop.visibility = View.VISIBLE - viewBinding.viewScrimBottom.visibility = View.GONE - } - 2 -> { - viewBinding.viewScrimTop.visibility = View.GONE - viewBinding.viewScrimBottom.visibility = View.VISIBLE - } - else -> { - viewBinding.viewScrimTop.visibility = View.GONE - viewBinding.viewScrimBottom.visibility = View.GONE - } - } + // Scrim mode: 0 = full screen, 1 = top edge, 2 = bottom edge, 3 = both edges, 4 = off. + // Mode 0 used to be documented (and advertised in Settings) as "Full Screen Scrim" + // while drawing exactly the same top+bottom edges as mode 3. + val full = config.scrimMode == 0 + val top = config.scrimMode == 0 || config.scrimMode == 1 || config.scrimMode == 3 + val bottom = config.scrimMode == 0 || config.scrimMode == 2 || config.scrimMode == 3 + viewBinding.viewScrimFull.visibility = if (full) View.VISIBLE else View.GONE + viewBinding.viewScrimTop.visibility = if (top) View.VISIBLE else View.GONE + viewBinding.viewScrimBottom.visibility = if (bottom) View.VISIBLE else View.GONE } } + /** + * Decodes [file] at most as large as the screen, halving the sample size until the result + * would be smaller than the target. Keeps a 4000x3000 phone photo at ~2 MP instead of 12 MP. + */ + private fun decodeSampledWallpaper(file: File, reqWidth: Int, reqHeight: Int): Bitmap? = + runCatching { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, bounds) + + var sample = 1 + if (bounds.outWidth > 0 && bounds.outHeight > 0 && reqWidth > 0 && reqHeight > 0) { + while (bounds.outWidth / (sample * 2) >= reqWidth && + bounds.outHeight / (sample * 2) >= reqHeight + ) { + sample *= 2 + } + } + BitmapFactory.decodeFile( + file.absolutePath, + BitmapFactory.Options().apply { inSampleSize = sample }, + ) + }.getOrNull() + fun onRescanRequested() { if (_binding == null || !isAdded) return viewLifecycleOwner.lifecycleScope.launch { @@ -239,7 +291,10 @@ class TvLauncherFragment : Fragment() { } private suspend fun loadApps() { - allApps = AppRepository.scan(requireContext()) + val context = requireContext().applicationContext + // scan() pins itself to Dispatchers.IO; the fragment only touches the result. + allApps = AppRepository.scan(context) + hasLoadedApps = true rebuildAppList() } @@ -264,7 +319,10 @@ class TvLauncherFragment : Fragment() { } private fun rebuildAppList() { - if (allApps.isEmpty()) return + // Skip only the emissions that arrive *before* the first scan. An empty list after a + // scan is a real state (everything hidden / uninstalled) and has to clear the grid - + // the old `if (allApps.isEmpty()) return` left stale tiles on screen. + if (!hasLoadedApps) return val visible = if (currentConfig.showHidden) allApps else allApps.filter { it.pkg !in currentConfig.hidden } val ordered = if (currentConfig.order.isNotEmpty()) { @@ -291,4 +349,9 @@ class TvLauncherFragment : Fragment() { super.onDestroyView() _binding = null } + + private companion object { + /** File name the Settings photo picker writes the custom wallpaper to. */ + const val WALLPAPER_FILE = "wallpaper.jpg" + } } diff --git a/app/src/main/java/com/gothwad/launcher/ui/view/StatusBarView.kt b/app/src/main/java/com/gothwad/launcher/ui/view/StatusBarView.kt index 98ab182..96d1288 100644 --- a/app/src/main/java/com/gothwad/launcher/ui/view/StatusBarView.kt +++ b/app/src/main/java/com/gothwad/launcher/ui/view/StatusBarView.kt @@ -2,7 +2,9 @@ package com.gothwad.launcher.ui.view import android.content.Context import android.graphics.Color +import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable import android.util.AttributeSet import android.view.LayoutInflater import android.view.View @@ -33,6 +35,9 @@ class StatusBarView @JvmOverloads constructor( var onSettingsClick: (() -> Unit)? = null var onA11yAlertClick: (() -> Unit)? = null + /** Tapping/focusing the centre clock cluster opens the Quick Dashboard. */ + var onDashboardClick: (() -> Unit)? = null + init { setupStaticIcons() setupListeners() @@ -57,6 +62,9 @@ class StatusBarView @JvmOverloads constructor( binding.btnNotifications.setOnClickListener { onNotificationsClick?.invoke() } binding.btnSettings.setOnClickListener { onSettingsClick?.invoke() } binding.btnA11yAlert.setOnClickListener { onA11yAlertClick?.invoke() } + // The clock cluster is the Quick Dashboard entry point (network, Bluetooth remote, + // volume). That panel existed but nothing in the UI could open it. + binding.clusterCenter.setOnClickListener { onDashboardClick?.invoke() } } fun setA11yAlertVisible(visible: Boolean) { @@ -73,42 +81,52 @@ class StatusBarView @JvmOverloads constructor( } val strokePx = (1f * density).toInt() - val bgLeft = GradientDrawable().apply { + // All three clusters look the same, so they share one builder (this used to be the + // same 12-line GradientDrawable block pasted three times). Each view still needs its + // own Drawable instance - sharing one would share bounds/state/callback. + val glass = config.statusBarGlass + binding.clusterLeft.background = clusterBackground(radiusPx, strokePx, glass) + binding.clusterCenter.background = clusterBackground(radiusPx, strokePx, glass) + binding.clusterRight.background = clusterBackground(radiusPx, strokePx, glass) + } + + /** + * Glass/solid cluster background *with* focus and hover states. + * + * The centre cluster is D-pad reachable (it opens the Quick Dashboard), and a plain + * [GradientDrawable] gives a focused item no visual feedback at all - unusable with a + * remote. Each call returns a fresh [StateListDrawable]: drawables cannot be shared + * between views without sharing mutable state (bounds, level, callback). + */ + private fun clusterBackground(radiusPx: Float, strokePx: Int, glass: Boolean): Drawable { + fun panel(fill: Int, stroke: Int) = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE cornerRadius = radiusPx - if (config.statusBarGlass) { - setColor(ContextCompat.getColor(context, R.color.surface_glass)) - setStroke(strokePx, ContextCompat.getColor(context, R.color.surface_glass_stroke)) - } else { - setColor(Color.parseColor("#212124")) - setStroke(strokePx, Color.parseColor("#33FFFFFF")) - } + setColor(fill) + setStroke(strokePx, stroke) } - val bgCenter = GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = radiusPx - if (config.statusBarGlass) { - setColor(ContextCompat.getColor(context, R.color.surface_glass)) - setStroke(strokePx, ContextCompat.getColor(context, R.color.surface_glass_stroke)) - } else { - setColor(Color.parseColor("#212124")) - setStroke(strokePx, Color.parseColor("#33FFFFFF")) - } + + val (fill, stroke) = if (glass) { + ContextCompat.getColor(context, R.color.surface_glass) to + ContextCompat.getColor(context, R.color.surface_glass_stroke) + } else { + Color.parseColor("#212124") to Color.parseColor("#33FFFFFF") } - val bgRight = GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = radiusPx - if (config.statusBarGlass) { - setColor(ContextCompat.getColor(context, R.color.surface_glass)) - setStroke(strokePx, ContextCompat.getColor(context, R.color.surface_glass_stroke)) - } else { - setColor(Color.parseColor("#212124")) - setStroke(strokePx, Color.parseColor("#33FFFFFF")) - } + + return StateListDrawable().apply { + addState( + intArrayOf(android.R.attr.state_focused), + panel( + ContextCompat.getColor(context, R.color.status_pill_focused), + ContextCompat.getColor(context, R.color.accent_blue), + ), + ) + addState( + intArrayOf(android.R.attr.state_hovered), + panel(ContextCompat.getColor(context, R.color.status_pill_hover), stroke), + ) + addState(intArrayOf(), panel(fill, stroke)) } - binding.clusterLeft.background = bgLeft - binding.clusterCenter.background = bgCenter - binding.clusterRight.background = bgRight } fun setNetStatus(net: NetStatus) { diff --git a/app/src/main/res/drawable/bg_hero_card.xml b/app/src/main/res/drawable/bg_hero_card.xml deleted file mode 100644 index f043260..0000000 --- a/app/src/main/res/drawable/bg_hero_card.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/bg_settings_badge.xml b/app/src/main/res/drawable/bg_settings_badge.xml deleted file mode 100644 index 410051e..0000000 --- a/app/src/main/res/drawable/bg_settings_badge.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/bg_settings_row_clean.xml b/app/src/main/res/drawable/bg_settings_row_clean.xml deleted file mode 100644 index 1137bd8..0000000 --- a/app/src/main/res/drawable/bg_settings_row_clean.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/bg_sheet_bottom.xml b/app/src/main/res/drawable/bg_sheet_bottom.xml deleted file mode 100644 index a34c4e9..0000000 --- a/app/src/main/res/drawable/bg_sheet_bottom.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/bg_tv_settings_panel.xml b/app/src/main/res/drawable/bg_tv_settings_panel.xml deleted file mode 100644 index b358e79..0000000 --- a/app/src/main/res/drawable/bg_tv_settings_panel.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml deleted file mode 100644 index 53ee992..0000000 --- a/app/src/main/res/drawable/ic_add.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_lock_white_18dp.xml b/app/src/main/res/drawable/ic_lock_white_18dp.xml deleted file mode 100644 index b37fe41..0000000 --- a/app/src/main/res/drawable/ic_lock_white_18dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_remote.xml b/app/src/main/res/drawable/ic_remote.xml deleted file mode 100644 index 4f9e798..0000000 --- a/app/src/main/res/drawable/ic_remote.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/layout/dialog_edit_text.xml b/app/src/main/res/layout/dialog_edit_text.xml index b968743..8eed5cc 100644 --- a/app/src/main/res/layout/dialog_edit_text.xml +++ b/app/src/main/res/layout/dialog_edit_text.xml @@ -7,19 +7,21 @@ android:padding="24dp"> @@ -28,7 +30,7 @@ android:layout_width="match_parent" android:layout_height="50dp" android:background="@drawable/bg_dialog_card" - android:hint="e.g. #hidden, vault, secret" + android:hint="@string/reveal_code_hint_example" android:importantForAutofill="no" android:inputType="text" android:paddingHorizontal="16dp" @@ -48,7 +50,7 @@ android:layout_width="wrap_content" android:layout_height="44dp" android:background="@drawable/bg_dialog_button" - android:text="Cancel" + android:text="@string/cancel" android:textColor="#FFFFFF" />