From 78273eac4f0f2e47f191d5ac318869a6dc97139d Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Tue, 19 May 2026 23:01:27 +0800 Subject: [PATCH 01/45] Add manual-dispatch Release workflow + docs New `.github/workflows/release.yml` (`workflow_dispatch` only) builds `snaglite.exe` and four debug APKs in parallel, renames assets with the chosen version tag, and attaches all five to a new GitHub Release via `softprops/action-gh-release@v2`. Inputs: `tag` (required), `prerelease` (bool), `notes` (Markdown; auto-generated from commits if blank). README gets a new "Cutting a release" runbook and updates the three "where to get binaries" paragraphs to point at the Releases page. CLAUDE.md gets a CI table describing all three workflows and a note on the release-signing upgrade path. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release.yml | 124 ++++++++++++++++++++++++++++++++++ CLAUDE.md | 12 ++++ README.md | 40 +++++++++-- 3 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a516027 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,124 @@ +name: Release + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g. v0.1.0). Tag will be created on the commit that runs the workflow.' + required: true + type: string + prerelease: + description: 'Mark as pre-release?' + required: false + type: boolean + default: false + notes: + description: 'Optional release notes (Markdown). Leave blank to auto-generate from commits.' + required: false + type: string + +concurrency: + group: release-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + build-win: + name: Build snaglite.exe + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore src/SnagLite/SnagLite.csproj + + - name: Publish + run: dotnet publish src/SnagLite/SnagLite.csproj -c Release -r win-x64 --self-contained -o publish --no-restore + + - name: Upload snaglite.exe + uses: actions/upload-artifact@v4 + with: + name: win-x64 + path: publish/snaglite.exe + if-no-files-found: error + + build-android: + name: Build Android debug APKs + runs-on: ubuntu-latest + defaults: + run: + working-directory: android + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Build debug APKs + run: ./gradlew :app:assembleDebug --no-daemon --stacktrace + + - name: Upload debug APKs + uses: actions/upload-artifact@v4 + with: + name: android-debug + path: android/app/build/outputs/apk/debug/*.apk + if-no-files-found: error + + publish: + name: Create GitHub Release + needs: [build-win, build-android] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Stage release assets with version tag in filename + run: | + set -euo pipefail + mkdir -p release + cp "dist/win-x64/snaglite.exe" "release/snaglite-${{ inputs.tag }}-win-x64.exe" + for f in dist/android-debug/*.apk; do + base=$(basename "$f" .apk) + cp "$f" "release/snaglite-${{ inputs.tag }}-${base#app-}.apk" + done + ls -lh release/ + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.tag }} + name: ${{ inputs.tag }} + body: ${{ inputs.notes }} + generate_release_notes: true + prerelease: ${{ inputs.prerelease }} + files: release/* + fail_on_unmatched_files: true diff --git a/CLAUDE.md b/CLAUDE.md index 5708fe7..7535bcb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,18 @@ dotnet publish src/SnagLite/SnagLite.csproj -c Release -r win-x64 --self-contain cd android && .\gradlew.bat :app:assembleRelease # → app-{arm64-v8a,armeabi-v7a,x86_64,universal}-release.apk ``` +## CI + +Three GitHub Actions workflows live under `.github/workflows/`: + +| Workflow | Trigger | Output | +|---|---|---| +| `dotnet-cli.yml` | push / PR to `src/**` | `snaglite.exe` as 90-day Actions artifact | +| `android-debug.yml` | push / PR to `android/**` | 4 debug APKs as 90-day Actions artifact | +| `release.yml` | **manual only** (`workflow_dispatch`) | `snaglite.exe` + 4 debug APKs attached to a new GitHub Release. Inputs: `tag` (required, e.g. `v0.1.0`), `prerelease` (bool, default false), `notes` (Markdown; auto-generated from commits if blank). Asset filenames are rewritten to include the tag (`snaglite--win-x64.exe`, `snaglite---debug.apk`). User-facing runbook lives in the README **Cutting a release** section. | + +Release-signed (production) APKs are not wired into `release.yml` yet — it builds `assembleDebug`. Promoting to `assembleRelease` is a one-line swap once the four signing env vars are added as GitHub Secrets (`SNAGLITE_KEYSTORE_PATH/_PASS/_ALIAS`, `SNAGLITE_KEY_PASS`) and the keystore is uploaded as a base64-encoded secret + decoded in a step before the Gradle invocation. + ## Runtime data | Platform | Binaries | Config / prefs | Default output | diff --git a/README.md b/README.md index 7eebff8..c15cf8e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Both builds share the same philosophy: no accounts, no telemetry, no nags, no PA ## Quick start -**Windows CLI** — grab `snaglite.exe` from a GitHub Actions artifact (or build it yourself), drop it on your `PATH`, and: +**Windows CLI** — grab `snaglite.exe` from the [Releases page](https://github.com/corecompiled/SnagLite/releases) (or build it yourself), drop it on your `PATH`, and: ```powershell snaglite https://www.youtube.com/watch?v=dQw4w9WgXcQ @@ -28,7 +28,7 @@ snaglite https://www.youtube.com/watch?v=dQw4w9WgXcQ First invocation auto-downloads `yt-dlp.exe`, `ffmpeg.exe`, and `aria2c.exe` to `%LOCALAPPDATA%\SnagLite\bin`. From then on it's instant. -**Android** — build the APK with `./gradlew :app:assembleDebug` (or pull one from CI), sideload, paste a URL into the input box, tap **Download**. The setup screen on first launch handles the rest. Files land in `Movies/SnagLite` or `Music/SnagLite` and show up in your gallery without any storage permission prompts on Android 10+. +**Android** — download an APK from the [Releases page](https://github.com/corecompiled/SnagLite/releases) (or build it yourself with `./gradlew :app:assembleDebug`), sideload, paste a URL into the input box, tap **Download**. The setup screen on first launch handles the rest. Files land in `Movies/SnagLite` or `Music/SnagLite` and show up in your gallery without any storage permission prompts on Android 10+. ## What makes it different @@ -67,7 +67,7 @@ If a site's player obfuscates the stream URL beyond what the bundled extractors ### Install -1. Grab `snaglite.exe` from `publish/` (after running the publish command below) or from a release. +1. Grab `snaglite.exe` from the [Releases page](https://github.com/corecompiled/SnagLite/releases), or from `publish/` after running the publish command below. 2. Drop it anywhere on your `PATH` (e.g. `C:\Tools\snaglite.exe`). 3. First invocation auto-downloads helper binaries to `%LOCALAPPDATA%\SnagLite\bin`. @@ -181,7 +181,7 @@ Most public videos work without sign-in. Age-gated, members-only, or aggressivel ### Install -Build artifacts land in `android/app/build/outputs/apk/`: +Pre-built debug APKs are attached to each entry on the [Releases page](https://github.com/corecompiled/SnagLite/releases) — pick the variant for your device and sideload. To build locally instead, build artifacts land in `android/app/build/outputs/apk/`: | APK | Use case | |---|---| @@ -261,6 +261,38 @@ Tap the gear icon on the main screen. --- +## Cutting a release + +Releases (`snaglite.exe` + 4 debug APKs, attached to a tag at https://github.com/corecompiled/SnagLite/releases) are cut by a **manually-triggered** GitHub Actions workflow. There is no auto-publish on tag push — every release is an explicit decision. + +1. Make sure `main` builds clean locally: + ```powershell + dotnet publish src/SnagLite/SnagLite.csproj -c Release -r win-x64 --self-contained -o publish + cd android; .\gradlew.bat :app:assembleDebug; cd .. + ``` +2. Push everything you want in the release to `main` (or whichever branch you'll release from). +3. Open **https://github.com/corecompiled/SnagLite/actions/workflows/release.yml** in a browser. +4. Click the grey **Run workflow** dropdown (top right). +5. Fill in: + - **Branch** — usually `main`. The chosen commit is what the tag attaches to. + - **Release tag** — semver with a `v` prefix, e.g. `v0.1.0`. Must not already exist. + - **Mark as pre-release?** — tick for alpha / beta, leave unticked for stable. + - **Release notes** — leave blank to auto-generate from commits since the previous tag, or paste your own Markdown. +6. Click the green **Run workflow** button. +7. Wait 5–10 minutes. The workflow runs three jobs in order: `build-win` and `build-android` in parallel, then `publish`. +8. When it goes green, the release appears at **https://github.com/corecompiled/SnagLite/releases** with 5 attached assets: + - `snaglite--win-x64.exe` + - `snaglite--arm64-v8a-debug.apk` + - `snaglite--armeabi-v7a-debug.apk` + - `snaglite--x86_64-debug.apk` + - `snaglite--universal-debug.apk` + +To **delete** a release (e.g. typo in the tag): on the Releases page, click the release title → trash icon. Then on the Tags page, delete the matching tag — otherwise re-running the workflow with the same tag fails with a tag-already-exists error. + +> APKs are currently **debug-signed** — installable, but a different signature from any future release-signed build, so users will need to uninstall before upgrading across a debug → release transition. Wiring up release signing in CI requires uploading the keystore + four passwords as GitHub Secrets; see `CLAUDE.md` for the upgrade path. + +--- + ## Migrating from Snag The project was previously called **Snag**; the rebrand to **SnagLite** is purely a naming change — engine, features, and supported sites are unchanged. From d3c8a5c2c9869717186fcad3f071e59dab83bbac Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Sun, 24 May 2026 02:12:42 +0800 Subject: [PATCH 02/45] Fix YouTube 403 + ship silent engine updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bundled changes that together make Android downloads "just work": 1. 403 auto-recovery - yausername library throws YoutubeDLException on yt-dlp non-zero exit rather than returning Response.exitCode != 0, so the existing classification branch never ran. Mirrored detection into both arms (catch + else). - Split 403 detection from the SIGN_IN regex. 403 is almost always an extractor-staleness symptom, not auth — routing it to sign-in misled users. - On any YouTube 403 or sign-in/bot error, ytDlpFlow now runs one silent YouTubeUpdater.updateNow + YouTubeBootstrapper.harvest then retries with the fallback player_client chain and yt-dlp's native HTTP downloader (aria2c amplifies googlevideo URL drift on retry). Sign-in state no longer gates the fallback retry. - Error card now surfaces "Update engine & retry" CTA for 403-class failures, separate from the sign-in CTA. 2. Silent engine updates ("it just works" polish) - EngineUpdateChecker.runSilentUpdateIfDue: no UI, wifi-only by default, 24 h-debounced. Fired from MainScreen LaunchedEffect (replacing the old AlertDialog prompt) and from SnagLiteApplication.onCreate after ensureEngines on setup-complete relaunches. - ENGINE_CHECK_INTERVAL_MS: 3 d -> 1 d. - SetupViewModel: ytdlp-update + visitor-harvest now run concurrently via coroutineScope/async, cutting perceived first-launch latency. - YouTubeHost.SIGN_IN_REGEX: dropped overly broad "This video is unavailable" (matched private + region-locked + deleted videos that signing in does not fix). README updated. Verified on Lenovo Y700 g4 (Android 16): fresh install -> Setup all-green -> first YouTube download silently succeeded via the 403 recovery path (refresh + retry, no UI). Co-Authored-By: Claude Opus 4.7 --- README.md | 24 ++--- .../patron/snaglite/SnagLiteApplication.kt | 12 ++- .../snaglite/download/DownloadController.kt | 28 +++++- .../patron/snaglite/download/DownloadItem.kt | 6 +- .../patron/snaglite/download/Downloader.kt | 67 +++++++++++--- .../patron/snaglite/ui/DownloadItemCard.kt | 34 ++++++-- .../java/com/patron/snaglite/ui/MainScreen.kt | 1 + .../com/patron/snaglite/ui/MainViewModel.kt | 1 + .../com/patron/snaglite/ui/SetupViewModel.kt | 27 ++++-- .../java/com/patron/snaglite/ui/UpdateGate.kt | 87 +++---------------- .../patron/snaglite/yt/EngineUpdateChecker.kt | 34 ++++++++ .../com/patron/snaglite/yt/YouTubeHost.kt | 16 +++- .../com/patron/snaglite/yt/YouTubePrefs.kt | 2 +- 13 files changed, 215 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index c15cf8e..ac6b651 100644 --- a/README.md +++ b/README.md @@ -170,14 +170,14 @@ Kotlin + Jetpack Compose APK. Wraps the same `yt-dlp` + `ffmpeg` + `aria2c` engi ### YouTube auth (automatic) -YouTube actively blocks anonymous requests. SnagLite handles this in three layers: +YouTube actively blocks anonymous requests. SnagLite handles this transparently — the user never sees a sign-in prompt unless a specific video genuinely needs an account. -1. **Auto-update yt-dlp** so latest extractor logic is in play. -2. **Per-request injection** of `--user-agent`, `--cookies`, `--add-header Accept-Language`, and `--extractor-args youtube:player_client=tv_simply,default,mweb;formats=missing_pot;visitor_data=…` (harvested via hidden WebView during setup). -3. **Auto-retry on bot/sign-in error** with a fallback `player_client` chain (`web_safari,android_vr,mweb`). Invisible. No user action. -4. **Sign-in fallback** — if both chains fail, the error card surfaces a **Sign in to YouTube** button. Tapping opens a full-screen WebView at Google login. On success SnagLite exports the cookies to `cookies.txt` and auto-retries the download. Cached forever. +1. **Silent background yt-dlp updates** — on every cold launch (wifi-only, 24 h-debounced) the app checks for a newer `yt-dlp` and installs it in the background. No dialog. The existing binary keeps serving downloads until the swap completes. +2. **Per-request injection** of `--user-agent`, `--cookies`, `--add-header Accept-Language`, and `--extractor-args youtube:player_client=tv_simply,default,mweb;formats=missing_pot;visitor_data=…` (harvested via hidden WebView during setup, refreshed automatically on failure). +3. **Auto-recover on 403 / bot / sign-in error** — silently re-runs `YouTubeUpdater.updateNow()` + `YouTubeBootstrapper.harvest()`, then retries once with the fallback `player_client` chain (`web_safari,android_vr,mweb`) and yt-dlp's native HTTP downloader (skipping aria2c on the retry — it can amplify googlevideo URL drift). +4. **CTAs on the error card** — only if recovery still fails. **Update engine & retry** for 403-class failures, **Sign in to YouTube** for genuine account-required errors (members-only, age-restricted, "Sign in to confirm…"). Sign-in opens a full-screen WebView at Google login; on success SnagLite exports the cookies and auto-retries. -Most public videos work without sign-in. Age-gated, members-only, or aggressively bot-checked videos fall through to step 4. +Most public videos work without any user action. The sign-in surface only appears on the rare videos that actually need a logged-in YouTube account. ### Install @@ -219,12 +219,11 @@ Tap the gear icon on the main screen. | Action | Effect | |---|---| -| **Sign in to YouTube** | Opens Google login WebView; persists cookies. | -| **Update yt-dlp now** | Force-pulls latest stable yt-dlp. | -| **Re-run setup** | Wipes setup flag; returns to first-launch SetupScreen. | -| **Clear YouTube cookies** | Deletes `yt-cookies.txt` + WebView cookies; resets sign-in state. | +| **Update download engine** | Force-pulls latest stable yt-dlp. Normally not needed — the app updates silently on launch. | +| **Re-run initial setup** | Wipes setup flag; returns to first-launch SetupScreen. | | **Delete file from device when removing** | Toggles the default state of the "also delete file" checkbox in the swipe-to-remove dialog. Per-swipe override always available. | -| Footer | Shows installed yt-dlp version. | + +Sign-in to YouTube isn't surfaced in Settings — it's offered automatically (as an error-card CTA) only on the rare videos that need an account. ### Defaults @@ -252,7 +251,8 @@ Tap the gear icon on the main screen. ### Troubleshooting -- **YouTube fails with "Please sign in" / 400 errors** — usually means primary + fallback `player_client` chains both regressed. Settings → **Update yt-dlp now**, then retry. If still failing, tap **Sign in to YouTube**. +- **YouTube 403 Forbidden** — extractor staleness vs. current YouTube signature/n-param scheme. The app silently updates `yt-dlp` on every cold launch (wifi-only, daily debounce). On a 403 it also auto-retries with a `visitor_data` re-harvest + fallback `player_client` chain. If both still fail, the error card shows **Update engine & retry**; if that fails too, hit Settings → **Update download engine** and retry. +- **YouTube fails with "Please sign in" / 400 errors** — primary + fallback `player_client` chains both regressed. Settings → **Update download engine**, then retry. If a video genuinely needs an account (age-restricted / members-only / "Sign in to confirm…"), the error card surfaces a **Sign in to YouTube** CTA. - **`Unsupported URL`** — auto-retries with `--force-generic-extractor`. If still fails, the embed host has no resolver yet — open an issue with the URL. - **`Couldn't extract video from `** — host changed its template. Pull `adb logcat -s ResolverA ResolverB ResolverC Packer DirectDownloader` and report the URL. Resolver patterns live under [`android/app/src/main/java/com/patron/snaglite/download/resolvers/`](android/app/src/main/java/com/patron/snaglite/download/resolvers/) and updates land in minutes. - **First-launch setup hangs at "Updating yt-dlp"** — slow network or GitHub throttling. Failure is non-fatal; the bundled yt-dlp will still work. Retry from Settings later. diff --git a/android/app/src/main/java/com/patron/snaglite/SnagLiteApplication.kt b/android/app/src/main/java/com/patron/snaglite/SnagLiteApplication.kt index 33dc44a..bf1d1c6 100644 --- a/android/app/src/main/java/com/patron/snaglite/SnagLiteApplication.kt +++ b/android/app/src/main/java/com/patron/snaglite/SnagLiteApplication.kt @@ -9,6 +9,7 @@ import com.patron.snaglite.download.DownloadController import com.patron.snaglite.download.MediaSink import com.patron.snaglite.download.SnagLitePrefs import com.patron.snaglite.download.resolvers.IframeUnwrapper +import com.patron.snaglite.yt.EngineUpdateChecker import com.patron.snaglite.yt.YouTubePrefs import com.yausername.aria2c.Aria2c import com.yausername.ffmpeg.FFmpeg @@ -57,9 +58,16 @@ class SnagLiteApplication : Application() { } } // Eager engine init only when setup is already complete; otherwise SetupScreen drives it. - // Engine update checks are gated by user consent (see EngineUpdateChecker + UpdateGate). + // After engines are ready, fire a silent yt-dlp update check (wifi-only, + // 24 h-debounced) so the binary stays current across cold launches without + // ever surfacing UI. if (YouTubePrefs.isSetupComplete(this)) { - applicationScope.launch { ensureEngines() } + applicationScope.launch { + ensureEngines() + if (enginesReady) { + EngineUpdateChecker.runSilentUpdateIfDue(this@SnagLiteApplication) + } + } } } diff --git a/android/app/src/main/java/com/patron/snaglite/download/DownloadController.kt b/android/app/src/main/java/com/patron/snaglite/download/DownloadController.kt index 0f198be..db3f266 100644 --- a/android/app/src/main/java/com/patron/snaglite/download/DownloadController.kt +++ b/android/app/src/main/java/com/patron/snaglite/download/DownloadController.kt @@ -7,6 +7,7 @@ import android.util.Log import com.patron.snaglite.SnagLiteApplication import com.patron.snaglite.service.DownloadService import com.patron.snaglite.yt.YouTubePlaylist +import com.patron.snaglite.yt.YouTubeUpdater import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -134,6 +135,23 @@ class DownloadController( resume(id) } + /** + * Run a foreground yt-dlp update, then re-queue the given item. Used by the + * "Update engine" CTA on a 403 error card. + */ + fun updateEngineAndRetry(id: String) { + val item = currentItem(id) ?: return + updateItem(id) { + it.copy(status = DownloadStatus.Error("Updating download engine…")) + } + scope.launch { + val ok = YouTubeUpdater.updateNow(app) + Log.i(TAG, "manual engine update on retry: ok=$ok (item=$id)") + // resume() expects Paused or Error; we just set Error above, so this re-queues. + resume(item.id) + } + } + private suspend fun pumpQueue() = mutex.withLock { val snapshot = _items.value val activeCount = snapshot.count { it.isActive } @@ -285,11 +303,19 @@ class DownloadController( } is DownloadResult.Failure -> { if (result.needsYouTubeSignIn) pendingSignInId = id + val needsEngineUpdate = result.needs403Recovery && !result.needsYouTubeSignIn + val displayMessage = when { + needsEngineUpdate -> + "YouTube blocked this download (403). The engine refresh + retry didn't recover — " + + "tap Update engine, or open Settings → Update download engine, then retry." + else -> result.message + } updateItem(id) { it.copy( status = DownloadStatus.Error( - message = result.message, + message = displayMessage, needsYouTubeSignIn = result.needsYouTubeSignIn, + needsEngineUpdate = needsEngineUpdate, ), ) } diff --git a/android/app/src/main/java/com/patron/snaglite/download/DownloadItem.kt b/android/app/src/main/java/com/patron/snaglite/download/DownloadItem.kt index 374731d..7375d82 100644 --- a/android/app/src/main/java/com/patron/snaglite/download/DownloadItem.kt +++ b/android/app/src/main/java/com/patron/snaglite/download/DownloadItem.kt @@ -8,7 +8,11 @@ sealed interface DownloadStatus { data class Running(val percent: Float, val line: String) : DownloadStatus data class Paused(val percent: Float, val line: String) : DownloadStatus data class Done(val publicUri: Uri, val displayName: String) : DownloadStatus - data class Error(val message: String, val needsYouTubeSignIn: Boolean = false) : DownloadStatus + data class Error( + val message: String, + val needsYouTubeSignIn: Boolean = false, + val needsEngineUpdate: Boolean = false, + ) : DownloadStatus } data class DownloadItem( diff --git a/android/app/src/main/java/com/patron/snaglite/download/Downloader.kt b/android/app/src/main/java/com/patron/snaglite/download/Downloader.kt index dfb1454..2a62e15 100644 --- a/android/app/src/main/java/com/patron/snaglite/download/Downloader.kt +++ b/android/app/src/main/java/com/patron/snaglite/download/Downloader.kt @@ -5,8 +5,10 @@ import android.net.Uri import android.util.Log import com.patron.snaglite.download.resolvers.ResolverPipeline import com.patron.snaglite.yt.YouTubeArgsInjector +import com.patron.snaglite.yt.YouTubeBootstrapper import com.patron.snaglite.yt.YouTubeHost import com.patron.snaglite.yt.YouTubePrefs +import com.patron.snaglite.yt.YouTubeUpdater import com.yausername.youtubedl_android.YoutubeDL import com.yausername.youtubedl_android.YoutubeDLRequest import kotlinx.coroutines.Dispatchers @@ -18,6 +20,7 @@ sealed interface DownloadResult { data class Failure( val message: String, val needsYouTubeSignIn: Boolean = false, + val needs403Recovery: Boolean = false, ) : DownloadResult } @@ -94,7 +97,7 @@ object Downloader { fun killYtDlp(processId: String): Boolean = runCatching { YoutubeDL.getInstance().destroyProcessById(processId) }.getOrDefault(false) - private fun ytDlpFlow( + private suspend fun ytDlpFlow( context: Context, url: String, workDir: File, @@ -102,20 +105,28 @@ object Downloader { audioOnly: Boolean, onProgress: (Float, String) -> Unit, ): DownloadResult { - val first = runOnce(context, url, workDir, processId, audioOnly, forceGeneric = false, useFallbackClients = false, onProgress) + val first = runOnce(context, url, workDir, processId, audioOnly, forceGeneric = false, useFallbackClients = false, skipAria2c = false, onProgress) if (first is DownloadResult.Failure && first.message.contains("Unsupported URL", ignoreCase = true)) { Log.i(TAG, "Unsupported URL — retrying with generic extractor") - val retry = runOnce(context, url, workDir, processId, audioOnly, forceGeneric = true, useFallbackClients = false, onProgress) + val retry = runOnce(context, url, workDir, processId, audioOnly, forceGeneric = true, useFallbackClients = false, skipAria2c = false, onProgress) return finalize(retry, workDir, audioOnly) } - // YouTube auto-retry: if primary player_client chain produced a sign-in/bot error, - // silently retry once with the fallback chain before surfacing the sign-in prompt. + // YouTube auto-recovery: a 403 or sign-in/bot error from the primary client chain + // is almost always an extractor-staleness symptom. Try once more after refreshing + // yt-dlp + visitor_data, with the fallback client chain and yt-dlp's native HTTP + // downloader (aria2c can amplify URL drift). Sign-in state is orthogonal — we + // retry even when signed in. if (first is DownloadResult.Failure && - first.needsYouTubeSignIn && + (first.needs403Recovery || first.needsYouTubeSignIn) && YouTubeHost.isYouTube(url) ) { - Log.i(TAG, "YouTube primary clients failed — retrying with fallback clients") - val retry = runOnce(context, url, workDir, processId, audioOnly, forceGeneric = false, useFallbackClients = true, onProgress) + Log.i(TAG, "YouTube blocked first attempt — refreshing engine + visitor_data then retrying") + onProgress(0f, "YouTube blocked download — refreshing…") + runCatching { YouTubeUpdater.updateNow(context) } + .onFailure { Log.w(TAG, "engine refresh failed: ${it.message}") } + runCatching { YouTubeBootstrapper.harvest(context) } + .onFailure { Log.w(TAG, "visitor_data refresh failed: ${it.message}") } + val retry = runOnce(context, url, workDir, processId, audioOnly, forceGeneric = false, useFallbackClients = true, skipAria2c = true, onProgress) return finalize(retry, workDir, audioOnly) } return finalize(first, workDir, audioOnly) @@ -143,6 +154,7 @@ object Downloader { audioOnly: Boolean, forceGeneric: Boolean, useFallbackClients: Boolean, + skipAria2c: Boolean, onProgress: (Float, String) -> Unit, ): DownloadResult { val req = YoutubeDLRequest(url).apply { @@ -153,8 +165,10 @@ object Downloader { addOption("--continue") addOption("--newline") - addOption("--external-downloader", "aria2c") - addOption("--external-downloader-args", "aria2c:-x16 -s16 -k1M --console-log-level=warn") + if (!skipAria2c) { + addOption("--external-downloader", "aria2c") + addOption("--external-downloader-args", "aria2c:-x16 -s16 -k1M --console-log-level=warn") + } if (forceGeneric) { addOption("--force-generic-extractor") @@ -182,15 +196,40 @@ object Downloader { if (response.exitCode == 0) { DownloadResult.Success(workDir, audioOnly) } else { + val combined = response.err + "\n" + response.out val msg = response.err.ifBlank { response.out.takeLast(400) } - val needsSignIn = YouTubeHost.isYouTube(url) && - YouTubeHost.isSignInError(response.err + "\n" + response.out) && + val isYouTubeUrl = YouTubeHost.isYouTube(url) + val needsSignIn = isYouTubeUrl && + YouTubeHost.isSignInError(combined) && !YouTubePrefs.isSignedIn(context) - DownloadResult.Failure(msg, needsYouTubeSignIn = needsSignIn) + val needs403 = isYouTubeUrl && + !needsSignIn && + YouTubeHost.is403Error(combined) + DownloadResult.Failure( + msg, + needsYouTubeSignIn = needsSignIn, + needs403Recovery = needs403, + ) } } catch (t: Throwable) { Log.e(TAG, "execute() threw", t) - DownloadResult.Failure(t.message ?: t.javaClass.simpleName) + // The yausername library raises YoutubeDLException for non-zero exits + // instead of returning a Response, so 403 / sign-in detection has to + // run on the thrown message too — otherwise the recovery branch above + // never sees the failure mode. + val text = t.message ?: t.javaClass.simpleName + val isYouTubeUrl = YouTubeHost.isYouTube(url) + val needsSignIn = isYouTubeUrl && + YouTubeHost.isSignInError(text) && + !YouTubePrefs.isSignedIn(context) + val needs403 = isYouTubeUrl && + !needsSignIn && + YouTubeHost.is403Error(text) + DownloadResult.Failure( + text, + needsYouTubeSignIn = needsSignIn, + needs403Recovery = needs403, + ) } } } diff --git a/android/app/src/main/java/com/patron/snaglite/ui/DownloadItemCard.kt b/android/app/src/main/java/com/patron/snaglite/ui/DownloadItemCard.kt index 30ec713..b74ac68 100644 --- a/android/app/src/main/java/com/patron/snaglite/ui/DownloadItemCard.kt +++ b/android/app/src/main/java/com/patron/snaglite/ui/DownloadItemCard.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayCircle import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox @@ -64,6 +65,7 @@ fun DownloadItemCard( onResume: () -> Unit, onRequestRemove: () -> Unit, onSignIn: () -> Unit, + onUpdateEngine: () -> Unit, ) { val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { value -> @@ -77,7 +79,7 @@ fun DownloadItemCard( SwipeToDismissBox( state = dismissState, backgroundContent = { SwipeBackground() }, - content = { CardBody(item, onPause, onResume, onSignIn) }, + content = { CardBody(item, onPause, onResume, onSignIn, onUpdateEngine) }, ) } @@ -104,6 +106,7 @@ private fun CardBody( onPause: () -> Unit, onResume: () -> Unit, onSignIn: () -> Unit, + onUpdateEngine: () -> Unit, ) { val ctx = LocalContext.current Card( @@ -142,7 +145,7 @@ private fun CardBody( SecondaryLine(item) StatusBlock(item.status) } - TrailingAction(item, onPause, onResume, onSignIn) { uri -> + TrailingAction(item, onPause, onResume, onSignIn, onUpdateEngine) { uri -> runCatching { ctx.startActivity( Intent(Intent.ACTION_VIEW).apply { @@ -332,6 +335,7 @@ private fun TrailingAction( onPause: () -> Unit, onResume: () -> Unit, onSignIn: () -> Unit, + onUpdateEngine: () -> Unit, onOpen: (android.net.Uri) -> Unit, ) { when (val s = item.status) { @@ -363,13 +367,27 @@ private fun TrailingAction( } } is DownloadStatus.Error -> { - if (s.needsYouTubeSignIn) { - FilledIconButton(onClick = onSignIn) { - Icon(Icons.Filled.PlayArrow, contentDescription = "Sign in & retry") + when { + s.needsYouTubeSignIn -> { + FilledIconButton(onClick = onSignIn) { + Icon(Icons.Filled.PlayArrow, contentDescription = "Sign in & retry") + } } - } else { - IconButton(onClick = onResume) { - Icon(Icons.Filled.Refresh, contentDescription = "Retry") + s.needsEngineUpdate -> { + FilledIconButton( + onClick = onUpdateEngine, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + ) { + Icon(Icons.Filled.SystemUpdate, contentDescription = "Update engine & retry") + } + } + else -> { + IconButton(onClick = onResume) { + Icon(Icons.Filled.Refresh, contentDescription = "Retry") + } } } } diff --git a/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt b/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt index 600e0ac..53665b5 100644 --- a/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt +++ b/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt @@ -167,6 +167,7 @@ fun MainScreen( vm.rememberPendingSignIn(item.id) signInLauncher.launch(Intent(ctx, YouTubeSignInActivity::class.java)) }, + onUpdateEngine = { vm.updateEngineAndRetry(item.id) }, ) } } diff --git a/android/app/src/main/java/com/patron/snaglite/ui/MainViewModel.kt b/android/app/src/main/java/com/patron/snaglite/ui/MainViewModel.kt index 2dfe8e9..6443452 100644 --- a/android/app/src/main/java/com/patron/snaglite/ui/MainViewModel.kt +++ b/android/app/src/main/java/com/patron/snaglite/ui/MainViewModel.kt @@ -39,4 +39,5 @@ class MainViewModel(private val app: Application) : AndroidViewModel(app) { fun rememberPendingSignIn(id: String) = sapp.downloads.rememberPendingSignIn(id) fun onSignInComplete() = sapp.downloads.retryAfterSignIn() + fun updateEngineAndRetry(id: String) = sapp.downloads.updateEngineAndRetry(id) } diff --git a/android/app/src/main/java/com/patron/snaglite/ui/SetupViewModel.kt b/android/app/src/main/java/com/patron/snaglite/ui/SetupViewModel.kt index 2a81858..ac5c2c9 100644 --- a/android/app/src/main/java/com/patron/snaglite/ui/SetupViewModel.kt +++ b/android/app/src/main/java/com/patron/snaglite/ui/SetupViewModel.kt @@ -7,6 +7,9 @@ import com.patron.snaglite.SnagLiteApplication import com.patron.snaglite.yt.YouTubeBootstrapper import com.patron.snaglite.yt.YouTubePrefs import com.patron.snaglite.yt.YouTubeUpdater +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -45,13 +48,23 @@ class SetupViewModel(private val app: Application) : AndroidViewModel(app) { val ok = sapp.ensureEngines() if (!ok) error(sapp.initError ?: "engine init failed") } - runStep("ytdlp") { - YouTubeUpdater.updateNow(app) - // failure non-fatal: the bundled yt-dlp still works; mark Done - } - runStep("youtube") { - YouTubeBootstrapper.harvest(app) - // failure non-fatal: extractor args still work without visitor_data + // ytdlp + visitor_data harvest are independent — yt-dlp update writes + // to internal storage, harvest hits a hidden WebView on the main + // thread. Run them concurrently to cut perceived setup latency. + coroutineScope { + val ytdlp = async { + runStep("ytdlp") { + YouTubeUpdater.updateNow(app) + // failure non-fatal: the bundled yt-dlp still works; mark Done + } + } + val youtube = async { + runStep("youtube") { + YouTubeBootstrapper.harvest(app) + // failure non-fatal: extractor args still work without visitor_data + } + } + awaitAll(ytdlp, youtube) } runStep("notif") { requestNotifPermission() diff --git a/android/app/src/main/java/com/patron/snaglite/ui/UpdateGate.kt b/android/app/src/main/java/com/patron/snaglite/ui/UpdateGate.kt index 26e657a..410474f 100644 --- a/android/app/src/main/java/com/patron/snaglite/ui/UpdateGate.kt +++ b/android/app/src/main/java/com/patron/snaglite/ui/UpdateGate.kt @@ -1,95 +1,28 @@ package com.patron.snaglite.ui import android.app.Application -import android.widget.Toast -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext -import com.patron.snaglite.SnagLiteApplication import com.patron.snaglite.yt.EngineUpdateChecker -import com.patron.snaglite.yt.YouTubePrefs -import com.patron.snaglite.yt.YouTubeUpdater import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** - * Checks once per app launch whether the download engine has a newer release. - * Cadence: every 3 days, with snooze. Prompts via AlertDialog; install only on consent. + * Silent, fire-and-forget engine update check at the top of every MainScreen + * composition. No UI surface — if a newer yt-dlp is available and we're on an + * unmetered network, it installs in the background while the existing version + * keeps working. Debounced to 24 h via [EngineUpdateChecker.runSilentUpdateIfDue]. + * + * Suppressed parameter `app` kept so existing call sites don't need editing. */ +@Suppress("UNUSED_PARAMETER") @Composable fun UpdateGate(app: Application) { val ctx = LocalContext.current - var pending by remember { mutableStateOf(null) } - var installing by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - val res = withContext(Dispatchers.IO) { EngineUpdateChecker.checkIfDue(ctx) } - if (res != null) pending = res - } - - pending?.let { res -> - AlertDialog( - onDismissRequest = { - if (!installing) { - YouTubePrefs.setEngineSnoozeUntil( - ctx, - System.currentTimeMillis() + YouTubePrefs.ENGINE_CHECK_INTERVAL_MS, - ) - pending = null - } - }, - title = { Text("Update available") }, - text = { - Text( - "A newer version of the download engine is available.\n\n" + - "Installed: ${res.current}\nLatest: ${res.latest}\n\n" + - "Install it now to keep video sites working reliably.", - style = MaterialTheme.typography.bodyMedium, - ) - }, - confirmButton = { - TextButton( - enabled = !installing, - onClick = { - installing = true - val sapp = app as SnagLiteApplication - sapp.applicationScope.launch { - val ok = YouTubeUpdater.updateNow(ctx) - withContext(Dispatchers.Main) { - Toast.makeText( - ctx, - if (ok) "Download engine updated." - else "Could not update the download engine.", - Toast.LENGTH_SHORT, - ).show() - installing = false - pending = null - } - } - }, - ) { Text(if (installing) "Updating…" else "Update now") } - }, - dismissButton = { - TextButton( - enabled = !installing, - onClick = { - YouTubePrefs.setEngineSnoozeUntil( - ctx, - System.currentTimeMillis() + YouTubePrefs.ENGINE_CHECK_INTERVAL_MS, - ) - pending = null - }, - ) { Text("Later") } - }, - ) + withContext(Dispatchers.IO) { + EngineUpdateChecker.runSilentUpdateIfDue(ctx) + } } } diff --git a/android/app/src/main/java/com/patron/snaglite/yt/EngineUpdateChecker.kt b/android/app/src/main/java/com/patron/snaglite/yt/EngineUpdateChecker.kt index 01d213e..98eee66 100644 --- a/android/app/src/main/java/com/patron/snaglite/yt/EngineUpdateChecker.kt +++ b/android/app/src/main/java/com/patron/snaglite/yt/EngineUpdateChecker.kt @@ -1,6 +1,8 @@ package com.patron.snaglite.yt import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.util.Log import com.yausername.youtubedl_android.YoutubeDL import kotlinx.coroutines.Dispatchers @@ -36,6 +38,38 @@ object EngineUpdateChecker { return checkNow(ctx) } + /** + * Fire-and-forget: if an update is due, install it silently. No UI, no + * AlertDialog. Skipped on metered networks by default so we don't burn the + * user's mobile data quota on a ~10 MB yt-dlp tarball. + * + * Called from app launch + after engine init. Idempotent: the 24 h debounce + * inside checkIfDue prevents back-to-back invocations from doing work. + */ + suspend fun runSilentUpdateIfDue(ctx: Context, wifiOnly: Boolean = true): Boolean = + withContext(Dispatchers.IO) { + if (wifiOnly && isMeteredNetwork(ctx)) { + Log.i(TAG, "metered network, deferring silent update") + return@withContext false + } + val due = checkIfDue(ctx) ?: return@withContext false + Log.i(TAG, "silent update due: ${due.current} -> ${due.latest}") + YouTubeUpdater.updateNow(ctx) + } + + /** + * True when the active network is metered (mobile data, hotspot, some VPNs). + * Returns false when there's no network at all — letting the update attempt + * proceed and fail naturally rather than silently skip in airplane mode. + */ + private fun isMeteredNetwork(ctx: Context): Boolean { + val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return false + val active = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(active) ?: return false + return !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + } + suspend fun checkNow(ctx: Context): Result? = withContext(Dispatchers.IO) { val current = runCatching { YoutubeDL.getInstance().version(ctx) } .getOrNull() diff --git a/android/app/src/main/java/com/patron/snaglite/yt/YouTubeHost.kt b/android/app/src/main/java/com/patron/snaglite/yt/YouTubeHost.kt index 0bf0adc..2050334 100644 --- a/android/app/src/main/java/com/patron/snaglite/yt/YouTubeHost.kt +++ b/android/app/src/main/java/com/patron/snaglite/yt/YouTubeHost.kt @@ -14,8 +14,17 @@ object YouTubeHost { "www.youtube-nocookie.com", ) + // Real sign-in / access-restriction signals. 403 alone is *not* one — it's + // almost always an extractor-staleness symptom on YouTube. "This video is + // unavailable" is also intentionally NOT here: it covers private + region- + // locked + deleted videos, none of which signing in fixes. private val SIGN_IN_REGEX = Regex( - "Sign in to confirm you.?re not a bot|Please sign in|HTTP Error 403|Private video|members-only|This video is unavailable|age-restricted|This video may be inappropriate", + "Sign in to confirm you.?re not a bot|Please sign in|members-only|age-restricted|This video may be inappropriate|Login required|This video requires payment|Private video", + RegexOption.IGNORE_CASE, + ) + + private val FORBIDDEN_REGEX = Regex( + "HTTP Error 403|\\b403 Forbidden\\b|HTTP/[\\d.]+ 403|status code 403|errorCode=403", RegexOption.IGNORE_CASE, ) @@ -29,4 +38,9 @@ object YouTubeHost { if (text.isNullOrBlank()) return false return SIGN_IN_REGEX.containsMatchIn(text) } + + fun is403Error(text: String?): Boolean { + if (text.isNullOrBlank()) return false + return FORBIDDEN_REGEX.containsMatchIn(text) + } } diff --git a/android/app/src/main/java/com/patron/snaglite/yt/YouTubePrefs.kt b/android/app/src/main/java/com/patron/snaglite/yt/YouTubePrefs.kt index ed7889d..dc44384 100644 --- a/android/app/src/main/java/com/patron/snaglite/yt/YouTubePrefs.kt +++ b/android/app/src/main/java/com/patron/snaglite/yt/YouTubePrefs.kt @@ -17,7 +17,7 @@ object YouTubePrefs { private const val VISITOR_TTL_MS = 30L * 24L * 60L * 60L * 1000L const val UPDATE_INTERVAL_MS = 7L * 24L * 60L * 60L * 1000L - const val ENGINE_CHECK_INTERVAL_MS = 3L * 24L * 60L * 60L * 1000L + const val ENGINE_CHECK_INTERVAL_MS = 1L * 24L * 60L * 60L * 1000L private fun sp(ctx: Context): SharedPreferences = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) From f861f3a2e40d8c7eacd813cec594f16a1c9beacc Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Wed, 27 May 2026 00:00:53 +0800 Subject: [PATCH 03/45] Wire release-signed Android APKs into release.yml The Release workflow now runs assembleRelease and signs the four ABI APKs with the keystore in SNAGLITE_KEYSTORE_B64 (base64-decoded into $RUNNER_TEMP at job start), gated by a fail-fast check on the secret so a missing keystore halts the run instead of silently shipping a debug-signed build. README and CLAUDE.md drift from the May 2026 R8 re-enable + this signing flip is fixed in the same pass: release asset filenames now say -release.apk, the "R8 currently disabled" notes are gone, and the "release signing not yet wired" paragraph in CLAUDE.md is replaced with a description of the actual decode-step flow. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release.yml | 32 ++++++++++++++++++++++++-------- CLAUDE.md | 8 ++++---- README.md | 14 +++++++------- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a516027..17ecc4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,7 +48,7 @@ jobs: if-no-files-found: error build-android: - name: Build Android debug APKs + name: Build Android release APKs runs-on: ubuntu-latest defaults: run: @@ -79,14 +79,30 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - - name: Build debug APKs - run: ./gradlew :app:assembleDebug --no-daemon --stacktrace - - - name: Upload debug APKs + - name: Decode release keystore + env: + KS_B64: ${{ secrets.SNAGLITE_KEYSTORE_B64 }} + run: | + set -euo pipefail + if [ -z "${KS_B64:-}" ]; then + echo "::error::SNAGLITE_KEYSTORE_B64 secret is empty; cannot build release-signed APKs." + exit 1 + fi + echo "$KS_B64" | base64 -d > "$RUNNER_TEMP/snaglite-release.jks" + echo "SNAGLITE_KEYSTORE_PATH=$RUNNER_TEMP/snaglite-release.jks" >> "$GITHUB_ENV" + + - name: Build release APKs + env: + SNAGLITE_KEYSTORE_PASS: ${{ secrets.SNAGLITE_KEYSTORE_PASS }} + SNAGLITE_KEY_ALIAS: ${{ secrets.SNAGLITE_KEY_ALIAS }} + SNAGLITE_KEY_PASS: ${{ secrets.SNAGLITE_KEY_PASS }} + run: ./gradlew :app:assembleRelease --no-daemon --stacktrace + + - name: Upload release APKs uses: actions/upload-artifact@v4 with: - name: android-debug - path: android/app/build/outputs/apk/debug/*.apk + name: android-release + path: android/app/build/outputs/apk/release/*.apk if-no-files-found: error publish: @@ -106,7 +122,7 @@ jobs: set -euo pipefail mkdir -p release cp "dist/win-x64/snaglite.exe" "release/snaglite-${{ inputs.tag }}-win-x64.exe" - for f in dist/android-debug/*.apk; do + for f in dist/android-release/*.apk; do base=$(basename "$f" .apk) cp "$f" "release/snaglite-${{ inputs.tag }}-${base#app-}.apk" done diff --git a/CLAUDE.md b/CLAUDE.md index 7535bcb..ca40019 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,9 +49,9 @@ Three GitHub Actions workflows live under `.github/workflows/`: |---|---|---| | `dotnet-cli.yml` | push / PR to `src/**` | `snaglite.exe` as 90-day Actions artifact | | `android-debug.yml` | push / PR to `android/**` | 4 debug APKs as 90-day Actions artifact | -| `release.yml` | **manual only** (`workflow_dispatch`) | `snaglite.exe` + 4 debug APKs attached to a new GitHub Release. Inputs: `tag` (required, e.g. `v0.1.0`), `prerelease` (bool, default false), `notes` (Markdown; auto-generated from commits if blank). Asset filenames are rewritten to include the tag (`snaglite--win-x64.exe`, `snaglite---debug.apk`). User-facing runbook lives in the README **Cutting a release** section. | +| `release.yml` | **manual only** (`workflow_dispatch`) | `snaglite.exe` + 4 release-signed APKs attached to a new GitHub Release. Inputs: `tag` (required, e.g. `v0.1.0`), `prerelease` (bool, default false), `notes` (Markdown; auto-generated from commits if blank). Asset filenames are rewritten to include the tag (`snaglite--win-x64.exe`, `snaglite---release.apk`). User-facing runbook lives in the README **Cutting a release** section. | -Release-signed (production) APKs are not wired into `release.yml` yet — it builds `assembleDebug`. Promoting to `assembleRelease` is a one-line swap once the four signing env vars are added as GitHub Secrets (`SNAGLITE_KEYSTORE_PATH/_PASS/_ALIAS`, `SNAGLITE_KEY_PASS`) and the keystore is uploaded as a base64-encoded secret + decoded in a step before the Gradle invocation. +Release signing is wired into `release.yml`: four GitHub Secrets — `SNAGLITE_KEYSTORE_B64` (base64 of the `.jks` file), `SNAGLITE_KEYSTORE_PASS`, `SNAGLITE_KEY_ALIAS`, `SNAGLITE_KEY_PASS` — drive the build. The "Decode release keystore" step writes the `.jks` to `$RUNNER_TEMP/snaglite-release.jks` and exports `SNAGLITE_KEYSTORE_PATH` via `$GITHUB_ENV`, so the subsequent `./gradlew :app:assembleRelease` invocation picks up all four env vars the `signingConfigs.release` block in `android/app/build.gradle.kts` expects. Missing `SNAGLITE_KEYSTORE_B64` is fail-fast — workflow halts rather than silently shipping debug-signed APKs. Keystore generation recipe is in `PRIVATE_NOTES.local.md`. ## Runtime data @@ -75,9 +75,9 @@ First-run migration is automatic on both platforms: ## Android release signing -`signingConfigs.release` reads four env vars: `SNAGLITE_KEYSTORE_PATH/_PASS/_ALIAS`, `SNAGLITE_KEY_PASS`. All four set + keystore exists → signed `app-{arm64-v8a,armeabi-v7a,x86_64,universal}-release.apk`. Otherwise Gradle prints one-line warning and falls back to local debug keystore so OSS clones still build. `proguard-rules.pro` already keeps `com.yausername.**` and `io.github.junkfood02.**`; Coil 3 + OkHttp 4 ship consumer rules. See `PRIVATE_NOTES.local.md` for `keytool` recipe + PowerShell env-var snippet. +`signingConfigs.release` reads four env vars: `SNAGLITE_KEYSTORE_PATH/_PASS/_ALIAS`, `SNAGLITE_KEY_PASS`. All four set + keystore exists → signed `app-{arm64-v8a,armeabi-v7a,x86_64,universal}-release.apk`. Otherwise Gradle prints one-line warning and falls back to local debug keystore so OSS clones still build. `proguard-rules.pro` keeps `com.yausername.**`, `io.github.junkfood02.**`, the commons-compress `AsiExtraField` + `ExtraFieldUtils` reflection surface, Compose runtime/ui, kotlinx.coroutines, Coil 3, OkHttp, and `com.patron.snaglite.**`. See `PRIVATE_NOTES.local.md` for `keytool` recipe + PowerShell env-var snippet. -> **R8 currently disabled.** Release has `isMinifyEnabled = false` + `isShrinkResources = false` while diagnosing a launch-time crash on Android 14. Re-enable both alongside additional keep rules (Coil 3, Compose runtime reflection, kotlinx.coroutines, OkHttp) once the baseline is confirmed good. The `proguardFiles(...)` line is kept in place so flipping the flags back is a one-line revert. +R8 + resource shrinker are **enabled** (`isMinifyEnabled = true` + `isShrinkResources = true`). A prior launch-time crash on Android 14 traced to commons-compress reflective loading being stripped; the `AsiExtraField` keep rule (plus the broader keep set above) is what unblocked re-enabling. If a future launch regression surfaces under R8, the `proguardFiles(...)` line and the keep block are the things to widen first. ## Android diagnostics diff --git a/README.md b/README.md index ac6b651..462473b 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ Most public videos work without any user action. The sign-in surface only appear ### Install -Pre-built debug APKs are attached to each entry on the [Releases page](https://github.com/corecompiled/SnagLite/releases) — pick the variant for your device and sideload. To build locally instead, build artifacts land in `android/app/build/outputs/apk/`: +Pre-built release-signed APKs are attached to each entry on the [Releases page](https://github.com/corecompiled/SnagLite/releases) — pick the variant for your device and sideload. To build locally instead, build artifacts land in `android/app/build/outputs/apk/`: | APK | Use case | |---|---| @@ -200,7 +200,7 @@ cd android .\gradlew.bat :app:assembleRelease # release APKs in app/build/outputs/apk/release/ ``` -The release build is signed with a release keystore if four env vars are set; otherwise it falls back to the local debug keystore (with a Gradle warning). R8 / resource-shrink is currently disabled in `release` while a launch-time crash on Android 14 is being diagnosed — see `CLAUDE.md` for context. +The release build is signed with a release keystore if four env vars are set; otherwise it falls back to the local debug keystore (with a Gradle warning). R8 + resource shrinking are enabled for `release`; keep rules live in `android/app/proguard-rules.pro`. | Env var | Purpose | |---|---| @@ -282,14 +282,14 @@ Releases (`snaglite.exe` + 4 debug APKs, attached to a tag at https://github.com 7. Wait 5–10 minutes. The workflow runs three jobs in order: `build-win` and `build-android` in parallel, then `publish`. 8. When it goes green, the release appears at **https://github.com/corecompiled/SnagLite/releases** with 5 attached assets: - `snaglite--win-x64.exe` - - `snaglite--arm64-v8a-debug.apk` - - `snaglite--armeabi-v7a-debug.apk` - - `snaglite--x86_64-debug.apk` - - `snaglite--universal-debug.apk` + - `snaglite--arm64-v8a-release.apk` + - `snaglite--armeabi-v7a-release.apk` + - `snaglite--x86_64-release.apk` + - `snaglite--universal-release.apk` To **delete** a release (e.g. typo in the tag): on the Releases page, click the release title → trash icon. Then on the Tags page, delete the matching tag — otherwise re-running the workflow with the same tag fails with a tag-already-exists error. -> APKs are currently **debug-signed** — installable, but a different signature from any future release-signed build, so users will need to uninstall before upgrading across a debug → release transition. Wiring up release signing in CI requires uploading the keystore + four passwords as GitHub Secrets; see `CLAUDE.md` for the upgrade path. +> APKs are **release-signed** with a stable certificate, so in-place upgrades work across releases going forward. Anyone who has an older **debug-signed** APK installed (from a release cut before the signing flip) must uninstall it first — Android will refuse to upgrade across a signature change with `INSTALL_FAILED_UPDATE_INCOMPATIBLE`. One-time pain; subsequent upgrades are seamless. --- From e609f24ce9e14bb747e8e1b1ca8a2832389fb83b Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Wed, 27 May 2026 00:25:46 +0800 Subject: [PATCH 04/45] Rename release assets so APKs sort before the .exe GitHub's release UI displays assets alphabetically. Adding the "android-" infix on APKs and renaming "win-x64" to "windows-x64" makes the four APKs occupy the first four positions in the asset list, with the Windows .exe last. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release.yml | 4 ++-- README.md | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 17ecc4e..c059c1d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,10 +121,10 @@ jobs: run: | set -euo pipefail mkdir -p release - cp "dist/win-x64/snaglite.exe" "release/snaglite-${{ inputs.tag }}-win-x64.exe" + cp "dist/win-x64/snaglite.exe" "release/snaglite-${{ inputs.tag }}-windows-x64.exe" for f in dist/android-release/*.apk; do base=$(basename "$f" .apk) - cp "$f" "release/snaglite-${{ inputs.tag }}-${base#app-}.apk" + cp "$f" "release/snaglite-${{ inputs.tag }}-android-${base#app-}.apk" done ls -lh release/ diff --git a/README.md b/README.md index 462473b..7da5e9b 100644 --- a/README.md +++ b/README.md @@ -281,11 +281,11 @@ Releases (`snaglite.exe` + 4 debug APKs, attached to a tag at https://github.com 6. Click the green **Run workflow** button. 7. Wait 5–10 minutes. The workflow runs three jobs in order: `build-win` and `build-android` in parallel, then `publish`. 8. When it goes green, the release appears at **https://github.com/corecompiled/SnagLite/releases** with 5 attached assets: - - `snaglite--win-x64.exe` - - `snaglite--arm64-v8a-release.apk` - - `snaglite--armeabi-v7a-release.apk` - - `snaglite--x86_64-release.apk` - - `snaglite--universal-release.apk` + - `snaglite--android-arm64-v8a-release.apk` + - `snaglite--android-armeabi-v7a-release.apk` + - `snaglite--android-universal-release.apk` + - `snaglite--android-x86_64-release.apk` + - `snaglite--windows-x64.exe` To **delete** a release (e.g. typo in the tag): on the Releases page, click the release title → trash icon. Then on the Tags page, delete the matching tag — otherwise re-running the workflow with the same tag fails with a tag-already-exists error. From 970a96293db82acdc8ff0403599bc53def8cd69a Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Wed, 27 May 2026 00:37:40 +0800 Subject: [PATCH 05/45] Default release body template when notes input is empty Workflow dispatches with no notes argument previously produced a release page whose entire body was the auto-generated "Full Changelog" link. Write a sectioned default body (tagline + asset list + quick start) when inputs.notes is blank; user-supplied notes still take precedence. softprops still appends the auto-generated "What's Changed" section after our body. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release.yml | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c059c1d..2b47967 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,13 +128,38 @@ jobs: done ls -lh release/ + - name: Build release body + env: + NOTES: ${{ inputs.notes }} + run: | + set -euo pipefail + if [ -n "${NOTES:-}" ]; then + printf '%s\n' "$NOTES" > release/BODY.md + else + cat > release/BODY.md <<'EOF' + Tiny video downloader. Paste a URL, get the file — highest available quality, no setup. + + ## What's in this release + + - Android APKs (release-signed) — `arm64-v8a`, `armeabi-v7a`, `x86_64`, and universal variants. + - `snaglite.exe` — single-file Windows CLI (.NET 10 self-contained, ~42 MB). + + ## Quick start + + - **Android**: sideload the APK matching your CPU. First launch bootstraps `yt-dlp` + `ffmpeg` + `aria2c` automatically. + - **Windows**: drop the exe on your `PATH`, then `snaglite `. + + Full docs: [README](https://github.com/corecompiled/SnagLite#readme). + EOF + fi + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ inputs.tag }} name: ${{ inputs.tag }} - body: ${{ inputs.notes }} + body_path: release/BODY.md generate_release_notes: true prerelease: ${{ inputs.prerelease }} - files: release/* + files: release/snaglite-* fail_on_unmatched_files: true From 03082d7647b5ebda20c347e4654237277b2b6581 Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Wed, 27 May 2026 01:08:49 +0800 Subject: [PATCH 06/45] Port Android YouTube hardening to Windows CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI was pure yt-dlp passthrough with zero anti-bot hardening while Android shipped a full mitigation stack. Port the subset that works without WebView or browser cookies: - YouTubeHost — host classifier + 403/sign-in stderr regex, byte-identical to the Kotlin original. - YouTubeArgsInjector — injects Android Chrome UA, Accept-Language, and --extractor-args youtube:player_client=tv_simply,default,mweb (or web_safari,android_vr,mweb on fallback) with formats=missing_pot. No visitor_data (no WebView on Windows), no cookies (matches the CLI's existing "no creds" policy). - EngineUpdateChecker — 7-day debounced silent yt-dlp refresh on every snaglite get, mirroring Android's weekly check. State at %APPDATA%\SnagLite\engine-update.json. - ToolResolver — new SilentRefreshYtDlpAsync (atomic .new swap, no Spectre UI) for the background path. - YtDlpRunner — new ExtraArgs option spliced into the args list; stderr is now accumulated in a StringBuilder so 403 detection works even when benign trailing lines follow the error. - DownloadCommand — kicks engine check on entry, awaits with 10 s cap before exit. On YouTube 403 or sign-in stderr, runs an engine refresh and retries once with the fallback player_client chain plus --no-aria2 (googlevideo URL drift amplifies under multi- connection downloaders). - README — Stays-current bullet now reflects auto-update on both builds; CLI troubleshooting gets the 403 auto-recovery bullet. Smoke-tested: snaglite where (tools resolved from cache), YouTube download (dQw4w9WgXcQ, 243 MB MP4, primary chain held — no 403), engine-update.json written + debounced on second run. Co-Authored-By: Claude Opus 4.7 --- README.md | 5 +- src/SnagLite/Commands/DownloadCommand.cs | 32 ++++++++-- src/SnagLite/Services/ToolResolver.cs | 24 +++++++ .../Services/YouTube/EngineUpdateChecker.cs | 62 +++++++++++++++++++ .../Services/YouTube/YouTubeArgsInjector.cs | 22 +++++++ src/SnagLite/Services/YouTube/YouTubeHost.cs | 43 +++++++++++++ src/SnagLite/Services/YtDlpRunner.cs | 13 +++- 7 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 src/SnagLite/Services/YouTube/EngineUpdateChecker.cs create mode 100644 src/SnagLite/Services/YouTube/YouTubeArgsInjector.cs create mode 100644 src/SnagLite/Services/YouTube/YouTubeHost.cs diff --git a/README.md b/README.md index 7da5e9b..3c9cf10 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ First invocation auto-downloads `yt-dlp.exe`, `ffmpeg.exe`, and `aria2c.exe` to - **Fast by default.** `aria2c -x16 -s16 -k1M` pulls files in parallel chunks — usually a 3–8× speedup over single-connection HTTP. - **Handles awkward sites.** When yt-dlp can't crack a host, SnagLite retries once with the generic extractor, and a small custom resolver pipeline handles a handful of common iframe-wrapper sites the bundled extractors miss. - **Android background reliability.** Holds a partial wake lock + high-perf Wi-Fi lock for the duration of a download, and prompts to add itself to the battery-optimisation exemption list on first launch so long downloads survive doze mode. -- **Stays current.** `snaglite update` pulls the latest yt-dlp release; the Android app does the same automatically every week (yt-dlp ships extractor fixes daily). +- **Stays current.** Both builds silently auto-update `yt-dlp` on a 7-day debounce per cold launch (yt-dlp ships extractor fixes daily). Manual `snaglite update` is also available on the CLI. - **No telemetry, no accounts, no nags.** Ever. > Inspired by [ytdlnis](https://github.com/deniscerri/ytdlnis). Previously known as **Snag** — see [Migrating from Snag](#migrating-from-snag) below for the rename details. @@ -128,7 +128,8 @@ snaglite --no-aria2 # use yt-dlp's native downloader instea ### Troubleshooting -- **`Unsupported URL`** — `snaglite update` to refresh yt-dlp, then retry. The CLI auto-retries once with the generic extractor on this error before giving up. +- **`Unsupported URL`** — auto-retries once with the generic extractor before surfacing the error. +- **YouTube 403 / sign-in error** — auto-recovers: silently re-updates yt-dlp, then retries once with a fallback `player_client` chain (`web_safari,android_vr,mweb`) and yt-dlp's native HTTP downloader (skipping aria2c, which can amplify googlevideo URL drift). Persistent failure usually means yt-dlp itself needs a manual `snaglite update`; cookies / sign-in are out of scope for the CLI by design (see Android for the WebView sign-in fallback). - **Geo-blocked** — yt-dlp's `--geo-bypass` is on by default; if a site requires actual VPN-level bypass it will still fail. Use a VPN. - **Slow downloads** — confirm aria2c is in use via `snaglite where`. Pass `--no-aria2` only if a CDN throttles parallel connections. - **Tool download fails** — corporate proxy, firewall, or GitHub rate limit. Manually drop `yt-dlp.exe`, `ffmpeg.exe`, and `aria2c.exe` into `%LOCALAPPDATA%\SnagLite\bin`. diff --git a/src/SnagLite/Commands/DownloadCommand.cs b/src/SnagLite/Commands/DownloadCommand.cs index d710c7b..82f7231 100644 --- a/src/SnagLite/Commands/DownloadCommand.cs +++ b/src/SnagLite/Commands/DownloadCommand.cs @@ -2,6 +2,7 @@ using SnagLite.Config; using SnagLite.Services; using SnagLite.Services.Resolvers; +using SnagLite.Services.YouTube; using Spectre.Console; using Spectre.Console.Cli; @@ -49,6 +50,8 @@ protected override async Task ExecuteAsync(CommandContext context, Settings await ToolResolver.EnsureAllAsync(cts.Token); + var engineCheck = EngineUpdateChecker.MaybeUpdateAsync(cts.Token); + var cfg = AppConfig.Load(); var outDir = cfg.ResolveOutputDir(settings.OutputDir); AnsiConsole.MarkupLineInterpolated($"[grey]Output:[/] {outDir}"); @@ -121,17 +124,38 @@ protected override async Task ExecuteAsync(CommandContext context, Settings FormatOverride = settings.Format, UseAria2 = !settings.NoAria2, ForceGeneric = false, + ExtraArgs = YouTubeArgsInjector.ExtraArgs(workUrl.ToString(), useFallbackClients: false), }; var result = await YtDlpRunner.RunAsync(opts, cts.Token); - if (result.ExitCode != 0 && - result.LastError.Contains("Unsupported URL", StringComparison.OrdinalIgnoreCase)) + if (result.ExitCode != 0) { - AnsiConsole.MarkupLine("[yellow]Unsupported URL — retrying with generic extractor...[/]"); - result = await YtDlpRunner.RunAsync(opts with { ForceGeneric = true }, cts.Token); + if (YouTubeHost.IsYouTube(opts.Url) && + (YouTubeHost.Is403Error(result.LastError) || YouTubeHost.IsSignInError(result.LastError))) + { + AnsiConsole.MarkupLine("[yellow]YouTube 403 / sign-in — refreshing engine and retrying with fallback player_client chain...[/]"); + try { await ToolResolver.ForceUpdateYtDlpAsync(cts.Token); } + catch (Exception ex) + { + AnsiConsole.MarkupLineInterpolated($"[yellow]Engine refresh failed ({ex.Message}); retrying anyway.[/]"); + } + result = await YtDlpRunner.RunAsync(opts with + { + UseAria2 = false, + ExtraArgs = YouTubeArgsInjector.ExtraArgs(opts.Url, useFallbackClients: true), + }, cts.Token); + } + else if (result.LastError.Contains("Unsupported URL", StringComparison.OrdinalIgnoreCase)) + { + AnsiConsole.MarkupLine("[yellow]Unsupported URL — retrying with generic extractor...[/]"); + result = await YtDlpRunner.RunAsync(opts with { ForceGeneric = true }, cts.Token); + } } + try { await engineCheck.WaitAsync(TimeSpan.FromSeconds(10)); } + catch { } + if (result.ExitCode == 0) { AnsiConsole.MarkupLine("[green]Done.[/]"); diff --git a/src/SnagLite/Services/ToolResolver.cs b/src/SnagLite/Services/ToolResolver.cs index 1fcdd3d..caefe32 100644 --- a/src/SnagLite/Services/ToolResolver.cs +++ b/src/SnagLite/Services/ToolResolver.cs @@ -65,6 +65,30 @@ await AnsiConsole.Status() AnsiConsole.MarkupLine("[green]✓[/] yt-dlp updated"); } + /// Silent (no UI) yt-dlp refresh. Downloads to a side file, then swaps in. + /// If yt-dlp.exe is currently executing the swap will fail with IOException + /// — that's expected and surfaced to the caller, which should swallow it. + public static async Task SilentRefreshYtDlpAsync(CancellationToken ct = default) + { + Paths.EnsureDir(Paths.BinDir); + var tmp = Paths.YtDlp + ".new"; + await using (var src = await Http.GetStreamAsync(YtDlpUrl, ct)) + await using (var dst = File.Create(tmp)) + { + await src.CopyToAsync(dst, ct); + } + try + { + if (File.Exists(Paths.YtDlp)) File.Delete(Paths.YtDlp); + File.Move(tmp, Paths.YtDlp); + } + catch + { + if (File.Exists(tmp)) { try { File.Delete(tmp); } catch { } } + throw; + } + } + private static async Task FetchYtDlpAsync(CancellationToken ct) { await using var src = await Http.GetStreamAsync(YtDlpUrl, ct); diff --git a/src/SnagLite/Services/YouTube/EngineUpdateChecker.cs b/src/SnagLite/Services/YouTube/EngineUpdateChecker.cs new file mode 100644 index 0000000..9484a47 --- /dev/null +++ b/src/SnagLite/Services/YouTube/EngineUpdateChecker.cs @@ -0,0 +1,62 @@ +using System.Text.Json; + +namespace SnagLite.Services.YouTube; + +public static class EngineUpdateChecker +{ + private static readonly TimeSpan UpdateInterval = TimeSpan.FromDays(7); + + private static string StateFile => + Path.Combine(Paths.ConfigDir, "engine-update.json"); + + private sealed class State + { + public DateTime LastChecked { get; set; } + } + + public static Task MaybeUpdateAsync(CancellationToken ct = default) + { + return Task.Run(async () => + { + try + { + Paths.EnsureDir(Paths.ConfigDir); + var state = LoadState(); + if (DateTime.UtcNow - state.LastChecked < UpdateInterval) return; + + try { await ToolResolver.SilentRefreshYtDlpAsync(ct); } + catch { } + + state.LastChecked = DateTime.UtcNow; + SaveState(state); + } + catch + { + } + }, ct); + } + + private static State LoadState() + { + try + { + if (!File.Exists(StateFile)) return new State(); + var json = File.ReadAllText(StateFile); + return JsonSerializer.Deserialize(json) ?? new State(); + } + catch + { + return new State(); + } + } + + private static void SaveState(State state) + { + try + { + var json = JsonSerializer.Serialize(state); + File.WriteAllText(StateFile, json); + } + catch { } + } +} diff --git a/src/SnagLite/Services/YouTube/YouTubeArgsInjector.cs b/src/SnagLite/Services/YouTube/YouTubeArgsInjector.cs new file mode 100644 index 0000000..2183b5c --- /dev/null +++ b/src/SnagLite/Services/YouTube/YouTubeArgsInjector.cs @@ -0,0 +1,22 @@ +namespace SnagLite.Services.YouTube; + +public static class YouTubeArgsInjector +{ + public const string Primary = "player_client=tv_simply,default,mweb;formats=missing_pot"; + public const string Fallback = "player_client=web_safari,android_vr,mweb;formats=missing_pot"; + + private const string Ua = + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"; + + public static IEnumerable ExtraArgs(string url, bool useFallbackClients = false) + { + if (!YouTubeHost.IsYouTube(url)) yield break; + + yield return "--user-agent"; + yield return Ua; + yield return "--add-header"; + yield return "Accept-Language:en-US,en;q=0.9"; + yield return "--extractor-args"; + yield return $"youtube:{(useFallbackClients ? Fallback : Primary)}"; + } +} diff --git a/src/SnagLite/Services/YouTube/YouTubeHost.cs b/src/SnagLite/Services/YouTube/YouTubeHost.cs new file mode 100644 index 0000000..e6d5a2d --- /dev/null +++ b/src/SnagLite/Services/YouTube/YouTubeHost.cs @@ -0,0 +1,43 @@ +using System.Text.RegularExpressions; + +namespace SnagLite.Services.YouTube; + +public static class YouTubeHost +{ + private static readonly HashSet Hosts = new(StringComparer.OrdinalIgnoreCase) + { + "youtube.com", + "youtu.be", + "m.youtube.com", + "music.youtube.com", + "youtube-nocookie.com", + "www.youtube.com", + "www.youtube-nocookie.com", + }; + + private static readonly Regex SignInRegex = new( + "Sign in to confirm you.?re not a bot|Please sign in|members-only|age-restricted|This video may be inappropriate|Login required|This video requires payment|Private video", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex ForbiddenRegex = new( + @"HTTP Error 403|\b403 Forbidden\b|HTTP/[\d.]+ 403|status code 403|errorCode=403", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public static bool IsYouTube(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var u)) return false; + var host = u.Host.ToLowerInvariant(); + if (Hosts.Contains(host)) return true; + foreach (var h in Hosts) + { + if (host.EndsWith("." + h, StringComparison.Ordinal)) return true; + } + return false; + } + + public static bool IsSignInError(string? text) => + !string.IsNullOrWhiteSpace(text) && SignInRegex.IsMatch(text); + + public static bool Is403Error(string? text) => + !string.IsNullOrWhiteSpace(text) && ForbiddenRegex.IsMatch(text); +} diff --git a/src/SnagLite/Services/YtDlpRunner.cs b/src/SnagLite/Services/YtDlpRunner.cs index 279b4fd..de1ff83 100644 --- a/src/SnagLite/Services/YtDlpRunner.cs +++ b/src/SnagLite/Services/YtDlpRunner.cs @@ -1,3 +1,4 @@ +using System.Text; using CliWrap; using CliWrap.EventStream; using Spectre.Console; @@ -12,6 +13,7 @@ public sealed record YtDlpOptions public string? FormatOverride { get; init; } public bool UseAria2 { get; init; } = true; public bool ForceGeneric { get; init; } + public IEnumerable? ExtraArgs { get; init; } } public sealed class YtDlpResult @@ -26,7 +28,7 @@ public static async Task RunAsync(YtDlpOptions opt, CancellationTok { var args = BuildArgs(opt); - var lastError = ""; + var stderrBuf = new StringBuilder(); AnsiConsole.MarkupLine($"[grey]→ {opt.Url}[/]"); var exit = await AnsiConsole.Progress() @@ -59,7 +61,7 @@ public static async Task RunAsync(YtDlpOptions opt, CancellationTok case StandardErrorCommandEvent stderr: if (!string.IsNullOrWhiteSpace(stderr.Text)) { - lastError = stderr.Text; + stderrBuf.AppendLine(stderr.Text); if (stderr.Text.StartsWith("ERROR", StringComparison.OrdinalIgnoreCase)) AnsiConsole.MarkupLineInterpolated($"[red]{stderr.Text}[/]"); } @@ -77,7 +79,7 @@ public static async Task RunAsync(YtDlpOptions opt, CancellationTok return code; }); - return new YtDlpResult { ExitCode = exit, LastError = lastError }; + return new YtDlpResult { ExitCode = exit, LastError = stderrBuf.ToString() }; } private static void HandleLine(string line, ProgressTask task) @@ -157,6 +159,11 @@ private static List BuildArgs(YtDlpOptions opt) args.Add("--merge-output-format"); args.Add("mp4"); } + if (opt.ExtraArgs is not null) + { + foreach (var ea in opt.ExtraArgs) args.Add(ea); + } + return args; } From 3eaabb73b7437afa3464db9defd34df55bf79739 Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:42:04 +0800 Subject: [PATCH 07/45] Exclude YouTube session cookies from Android backup Auto Backup was uploading filesDir wholesale, which includes the Netscape-format cookie jar CookieStore writes for yt-dlp, plus the WebView cookie database under app_webview. Both now excluded from cloud backup and device transfer, on API 30-and-below and 31-plus rule formats alike. --- README.md | 5 +++++ android/app/src/main/AndroidManifest.xml | 3 +++ android/app/src/main/res/xml/backup_rules.xml | 15 +++++++++++++++ .../src/main/res/xml/data_extraction_rules.xml | 17 +++++++++++++++++ 4 files changed, 40 insertions(+) create mode 100644 android/app/src/main/res/xml/backup_rules.xml create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml diff --git a/README.md b/README.md index 3c9cf10..2aa2f50 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,11 @@ YouTube actively blocks anonymous requests. SnagLite handles this transparently Most public videos work without any user action. The sign-in surface only appears on the rare videos that actually need a logged-in YouTube account. +Your YouTube session never leaves the device. The cookie jar SnagLite hands to +yt-dlp, the WebView's own cookie database, and the `snaglite_yt` preference file +are all excluded from Android's cloud backup and from device-to-device transfer. +Signing in on a new device means signing in again — by design. + ### Install Pre-built release-signed APKs are attached to each entry on the [Releases page](https://github.com/corecompiled/SnagLite/releases) — pick the variant for your device and sideload. To build locally instead, build artifacts land in `android/app/build/outputs/apk/`: diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2377ecc..3039762 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,9 @@ + + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..a4e1da1 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + From d41b0741b23361b4724e3276b39c431d4dcdfbd7 Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:23:21 +0800 Subject: [PATCH 08/45] Scope ViewModels correctly and handle back from Settings remember{} ties a ViewModel to the composition, so every configuration change built a new one and leaked the old one's viewModelScope. Use viewModel() so the default SavedStateViewModelFactory constructs and retains them. Now that SetupViewModel survives recomposition, guard start() so a config change during setup does not launch a second engine init. Settings was a boolean with no back handler, so back exited the app instead of returning to the list. --- .../src/main/java/com/patron/snaglite/MainActivity.kt | 5 +++-- .../src/main/java/com/patron/snaglite/ui/MainScreen.kt | 3 +++ .../main/java/com/patron/snaglite/ui/SetupViewModel.kt | 9 +++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/patron/snaglite/MainActivity.kt b/android/app/src/main/java/com/patron/snaglite/MainActivity.kt index 7520b68..06b832e 100644 --- a/android/app/src/main/java/com/patron/snaglite/MainActivity.kt +++ b/android/app/src/main/java/com/patron/snaglite/MainActivity.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat +import androidx.lifecycle.viewmodel.compose.viewModel import com.patron.snaglite.ui.MainScreen import com.patron.snaglite.ui.MainViewModel import com.patron.snaglite.ui.SetupScreen @@ -46,14 +47,14 @@ class MainActivity : ComponentActivity() { val app = application as SnagLiteApplication var setupDone by remember { mutableStateOf(YouTubePrefs.isSetupComplete(this)) } if (!setupDone) { - val vm = remember { SetupViewModel(app) } + val vm: SetupViewModel = viewModel() SetupScreen( vm = vm, onDone = { setupDone = true }, requestNotifPermission = { requestNotificationPermission() }, ) } else { - val vm = remember { MainViewModel(app) } + val vm: MainViewModel = viewModel() MainScreen( vm = vm, onResetSetup = { diff --git a/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt b/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt index 53665b5..eeca46c 100644 --- a/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt +++ b/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt @@ -1,6 +1,7 @@ package com.patron.snaglite.ui import android.content.Intent +import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement @@ -65,6 +66,8 @@ fun MainScreen( } } + BackHandler(enabled = showSettings) { showSettings = false } + if (showSettings) { SettingsScreen( onBack = { showSettings = false }, diff --git a/android/app/src/main/java/com/patron/snaglite/ui/SetupViewModel.kt b/android/app/src/main/java/com/patron/snaglite/ui/SetupViewModel.kt index ac5c2c9..a3a0c92 100644 --- a/android/app/src/main/java/com/patron/snaglite/ui/SetupViewModel.kt +++ b/android/app/src/main/java/com/patron/snaglite/ui/SetupViewModel.kt @@ -31,6 +31,13 @@ data class SetupUiState( class SetupViewModel(private val app: Application) : AndroidViewModel(app) { + /** + * The ViewModel now outlives the composition, so `LaunchedEffect(Unit)` in + * SetupScreen re-fires on every configuration change. Setup must run once + * per ViewModel instance, not once per composition. + */ + private var started = false + private val initial = listOf( SetupStep("engines", "Installing download engine"), SetupStep("ytdlp", "Updating download engine"), @@ -42,6 +49,8 @@ class SetupViewModel(private val app: Application) : AndroidViewModel(app) { val state: StateFlow = _state.asStateFlow() fun start(requestNotifPermission: suspend () -> Unit) { + if (started) return + started = true viewModelScope.launch { runStep("engines") { val sapp = app as SnagLiteApplication From c6350b853c7a8d93914163cb8754c4c5015efb29 Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:33:18 +0800 Subject: [PATCH 09/45] Move Android dependency versions into a version catalog No version changes: the resolved releaseRuntimeClasspath is identical before and after. This isolates the upcoming toolchain and library bumps to single-file diffs and gives Dependabot a catalog to read. --- android/app/build.gradle.kts | 48 +++++++++++++++---------------- android/build.gradle.kts | 4 +-- android/gradle/libs.versions.toml | 48 +++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 26 deletions(-) create mode 100644 android/gradle/libs.versions.toml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 184e019..b8e6901 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,16 +1,16 @@ plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) } android { namespace = "com.patron.snaglite" - compileSdk = 34 + compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { applicationId = "com.patron.snaglite" - minSdk = 26 - targetSdk = 34 + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 1 versionName = "1.0" vectorDrawables { useSupportLibrary = true } @@ -66,7 +66,7 @@ android { kotlinOptions { jvmTarget = "17" } buildFeatures { compose = true } - composeOptions { kotlinCompilerExtensionVersion = "1.5.14" } + composeOptions { kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() } splits { abi { @@ -88,26 +88,26 @@ android { } dependencies { - implementation("io.github.junkfood02.youtubedl-android:library:0.18.1") - implementation("io.github.junkfood02.youtubedl-android:ffmpeg:0.18.1") - implementation("io.github.junkfood02.youtubedl-android:aria2c:0.18.1") + implementation(libs.youtubedl.library) + implementation(libs.youtubedl.ffmpeg) + implementation(libs.youtubedl.aria2c) - val composeBom = platform("androidx.compose:compose-bom:2024.06.00") + val composeBom = platform(libs.compose.bom) implementation(composeBom) - implementation("androidx.compose.material3:material3") - implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.ui:ui-tooling-preview") - debugImplementation("androidx.compose.ui:ui-tooling") + implementation(libs.compose.material3) + implementation(libs.compose.material.icons.extended) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) - implementation("androidx.core:core-ktx:1.13.1") - implementation("androidx.activity:activity-compose:1.9.0") - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0") - implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") - implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.okhttp) - implementation("io.coil-kt.coil3:coil-compose:3.0.4") - implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.4") + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) } diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 017d909..7629126 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,4 +1,4 @@ plugins { - id("com.android.application") version "8.5.2" apply false - id("org.jetbrains.kotlin.android") version "1.9.24" apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..bae32fb --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,48 @@ +[versions] +# Toolchain +agp = "8.5.2" +kotlin = "1.9.24" +composeCompiler = "1.5.14" + +# SDK levels +compileSdk = "34" +minSdk = "26" +targetSdk = "34" + +# Libraries +composeBom = "2024.06.00" +coreKtx = "1.13.1" +activityCompose = "1.9.0" +lifecycle = "2.8.0" +coroutines = "1.8.1" +okhttp = "4.12.0" +coil = "3.0.4" +youtubedlAndroid = "0.18.1" + +[libraries] +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } + +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } + +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } + +coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } +coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } + +youtubedl-library = { group = "io.github.junkfood02.youtubedl-android", name = "library", version.ref = "youtubedlAndroid" } +youtubedl-ffmpeg = { group = "io.github.junkfood02.youtubedl-android", name = "ffmpeg", version.ref = "youtubedlAndroid" } +youtubedl-aria2c = { group = "io.github.junkfood02.youtubedl-android", name = "aria2c", version.ref = "youtubedlAndroid" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } From d1d68074ce8e46b761c7ffc4bb812f68e620b984 Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:44:16 +0800 Subject: [PATCH 10/45] Move to AGP 8.13.2, Gradle 8.13, Kotlin 2.2.20 Kotlin 2.0 moved the Compose compiler into the Kotlin repo and shipped it as org.jetbrains.kotlin.plugin.compose, so composeOptions and its kotlinCompilerExtensionVersion are gone; the plugin version is now just the Kotlin version. Daemon heap raised to 4G because K2 OOMs at 2G and reports it as a vanished daemon rather than an OOM. --- android/app/build.gradle.kts | 2 +- android/build.gradle.kts | 1 + android/gradle.properties | 4 +++- android/gradle/libs.versions.toml | 6 +++--- android/gradle/wrapper/gradle-wrapper.properties | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b8e6901..c593fe8 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) } android { @@ -66,7 +67,6 @@ android { kotlinOptions { jvmTarget = "17" } buildFeatures { compose = true } - composeOptions { kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() } splits { abi { diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 7629126..9deb573 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,4 +1,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false } diff --git a/android/gradle.properties b/android/gradle.properties index 9369896..fd214f9 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,6 @@ -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 +org.gradle.caching=true +org.gradle.parallel=true android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index bae32fb..fc69b1b 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,8 +1,7 @@ [versions] # Toolchain -agp = "8.5.2" -kotlin = "1.9.24" -composeCompiler = "1.5.14" +agp = "8.13.2" +kotlin = "2.2.20" # SDK levels compileSdk = "34" @@ -46,3 +45,4 @@ youtubedl-aria2c = { group = "io.github.junkfood02.youtubedl-android", name = "a [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index b82aa23..37f853b 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 271918e1fdf3b20988f8d2b84c1d7051a626929f Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:23:50 +0800 Subject: [PATCH 11/45] Bump AndroidX, Compose, Coil, OkHttp and coroutines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose BOM 2024.06 -> 2026.06.01 (Material3 1.4.0), activity 1.13.0, lifecycle 2.10.0, core 1.17.0, coroutines 1.11.0, OkHttp 5.4.0, Coil 3.4.0. Adds core-splashscreen 1.2.0 to the catalog for the next change; not yet wired up. Kotlin 2.2.20 -> 2.3.20 (and with it the Compose compiler plugin, which shares the version). Gradle resolves kotlin-stdlib to the highest version in the graph, and a stdlib newer than the compiler cannot be read — it surfaces as unresolved references to apply/emptyList in otherwise-correct files. AGP stays at 8.13.2, whose R8 8.13.19 supports Kotlin 2.3; Kotlin 2.4 would put R8, lint and the Compose plugin outside that envelope. compileSdk 34 -> 36 because activity, coil and okhttp-android all carry a compileSdk 36 AAR-metadata floor and the build fails at checkDebugAarMetadata without it. targetSdk stays 34 — that one forces edge-to-edge and moves only once the screens can survive it. core, lifecycle and Coil are pinned below their newest releases: core 1.19.0 needs compileSdk 37 + AGP 9.1, lifecycle 2.11.0 needs compileSdk 37 + AGP 9.2, and Coil 3.5.0 is built with Kotlin 2.4.0. This branch deliberately stays on AGP 8.13.2. Compose is unaffected — BOM 2026.06.01 is compose-ui 1.11.4, and the compileSdk 37 floor only arrives with Compose 1.12. youtubedl-android deliberately held at 0.18.1 — that one changes download behaviour and needs its own manual matrix. LocalClipboardManager is deprecated in this Compose version; paste now goes through LocalClipboard.getClipEntry() on a coroutine. --- android/app/build.gradle.kts | 9 ++++++++- .../java/com/patron/snaglite/ui/MainScreen.kt | 19 +++++++++++++++--- android/gradle/libs.versions.toml | 20 ++++++++++--------- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c593fe8..9aaa971 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) @@ -64,7 +66,6 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { jvmTarget = "17" } buildFeatures { compose = true } @@ -87,6 +88,12 @@ android { } } +kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } +} + dependencies { implementation(libs.youtubedl.library) implementation(libs.youtubedl.ffmpeg) diff --git a/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt b/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt index eeca46c..3fc55e3 100644 --- a/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt +++ b/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt @@ -35,7 +35,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -54,7 +56,8 @@ fun MainScreen( var url by remember { mutableStateOf("") } var showSettings by remember { mutableStateOf(false) } var pendingRemoval by remember { mutableStateOf(null) } - val clipboard = LocalClipboardManager.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() val ctx = LocalContext.current var showBattDialog by remember { mutableStateOf(shouldPromptBattOpt(ctx)) } @@ -107,7 +110,17 @@ fun MainScreen( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), trailingIcon = { IconButton(onClick = { - clipboard.getText()?.text?.let { url = it.trim() } + scope.launch { + clipboard.getClipEntry() + ?.clipData + ?.takeIf { it.itemCount > 0 } + ?.getItemAt(0) + ?.coerceToText(ctx) + ?.toString() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { url = it } + } }) { Icon(Icons.Filled.ContentPaste, contentDescription = "Paste") } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index fc69b1b..72a0235 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,21 +1,22 @@ [versions] # Toolchain agp = "8.13.2" -kotlin = "2.2.20" +kotlin = "2.3.20" # SDK levels -compileSdk = "34" +compileSdk = "36" minSdk = "26" targetSdk = "34" # Libraries -composeBom = "2024.06.00" -coreKtx = "1.13.1" -activityCompose = "1.9.0" -lifecycle = "2.8.0" -coroutines = "1.8.1" -okhttp = "4.12.0" -coil = "3.0.4" +composeBom = "2026.06.01" +coreKtx = "1.17.0" +splashscreen = "1.2.0" +activityCompose = "1.13.0" +lifecycle = "2.10.0" +coroutines = "1.11.0" +okhttp = "5.4.0" +coil = "3.4.0" youtubedlAndroid = "0.18.1" [libraries] @@ -27,6 +28,7 @@ compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } From 2b4771f964999225f5a1196061f72e1f90a61fe1 Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:53:49 +0800 Subject: [PATCH 12/45] Give the app a DayNight window theme and a real splash screen The window theme was android:Theme.Material.Light.NoActionBar, hardcoded light, so every cold launch in dark mode flashed white before Compose painted. Window background now resolves per-configuration, and the launcher activity uses the androidx SplashScreen compat layer instead of the system-synthesized one. --- android/app/build.gradle.kts | 1 + android/app/src/main/AndroidManifest.xml | 2 +- .../java/com/patron/snaglite/MainActivity.kt | 4 +++ .../app/src/main/res/values-night/colors.xml | 5 ++++ .../app/src/main/res/values-v29/themes.xml | 30 +++++++++++++++++++ android/app/src/main/res/values/colors.xml | 2 ++ android/app/src/main/res/values/themes.xml | 26 +++++++++++++++- 7 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/res/values-night/colors.xml create mode 100644 android/app/src/main/res/values-v29/themes.xml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9aaa971..5fddafd 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -108,6 +108,7 @@ dependencies { debugImplementation(libs.compose.ui.tooling) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3039762..4aa1364 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -31,7 +31,7 @@ android:name=".MainActivity" android:exported="true" android:label="@string/app_name" - android:theme="@style/Theme.SnagLite"> + android:theme="@style/Theme.SnagLite.Splash"> diff --git a/android/app/src/main/java/com/patron/snaglite/MainActivity.kt b/android/app/src/main/java/com/patron/snaglite/MainActivity.kt index 06b832e..7ee1efc 100644 --- a/android/app/src/main/java/com/patron/snaglite/MainActivity.kt +++ b/android/app/src/main/java/com/patron/snaglite/MainActivity.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.viewmodel.compose.viewModel import com.patron.snaglite.ui.MainScreen import com.patron.snaglite.ui.MainViewModel @@ -39,6 +40,9 @@ class MainActivity : ComponentActivity() { } override fun onCreate(savedInstanceState: Bundle?) { + // Must run before super.onCreate() — it swaps the window from + // Theme.SnagLite.Splash over to Theme.SnagLite. + installSplashScreen() super.onCreate(savedInstanceState) setContent { diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..7476ffb --- /dev/null +++ b/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + + #1C1B1F + #1C1B1F + diff --git a/android/app/src/main/res/values-v29/themes.xml b/android/app/src/main/res/values-v29/themes.xml new file mode 100644 index 0000000..2f0a054 --- /dev/null +++ b/android/app/src/main/res/values-v29/themes.xml @@ -0,0 +1,30 @@ + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 53ead9d..c74b17a 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,4 +1,6 @@ #FF1565C0 + #FFFBFE + #FFFBFE diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index abe0eff..a4d13b5 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -1,4 +1,28 @@ - + + + + From ba6c0b8b00894ceb3412ca17fc633f03d1157e78 Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:08:24 +0800 Subject: [PATCH 13/45] Put every screen behind one Material 3 Scaffold Screens were bare Columns with hand-rolled title rows, so there was no place to hang window insets, no snackbar host, and no scroll behaviour. One SnagLiteScaffold now owns the top bar, the snackbar host and the inset padding, which is what makes the upcoming targetSdk 36 edge-to-edge enforcement a configuration change instead of a per-screen audit. Settings feedback moves from Toast to Snackbar; Android 12+ rate-limits toasts and they read as foreign next to Material 3. --- .../java/com/patron/snaglite/ui/MainScreen.kt | 183 +++++++++--------- .../com/patron/snaglite/ui/SettingsScreen.kt | 141 +++++++------- .../com/patron/snaglite/ui/SetupScreen.kt | 30 +-- .../patron/snaglite/ui/SnagLiteScaffold.kt | 59 ++++++ 4 files changed, 233 insertions(+), 180 deletions(-) create mode 100644 android/app/src/main/java/com/patron/snaglite/ui/SnagLiteScaffold.kt diff --git a/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt b/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt index 3fc55e3..d1ff1b4 100644 --- a/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt +++ b/android/app/src/main/java/com/patron/snaglite/ui/MainScreen.kt @@ -6,6 +6,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -25,6 +26,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -83,108 +85,109 @@ fun MainScreen( } val hasFinished = items.any { it.isTerminal } + val snackbarHostState = remember { SnackbarHostState() } - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 20.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text( - "SnagLite", - style = MaterialTheme.typography.headlineLarge, - modifier = Modifier.weight(1f), - ) + SnagLiteScaffold( + title = "SnagLite", + snackbarHostState = snackbarHostState, + actions = { IconButton(onClick = { showSettings = true }) { Icon(Icons.Filled.Settings, contentDescription = "Settings") } - } - - OutlinedTextField( - value = url, - onValueChange = { url = it }, - label = { Text("Video URL") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - trailingIcon = { - IconButton(onClick = { - scope.launch { - clipboard.getClipEntry() - ?.clipData - ?.takeIf { it.itemCount > 0 } - ?.getItemAt(0) - ?.coerceToText(ctx) - ?.toString() - ?.trim() - ?.takeIf { it.isNotEmpty() } - ?.let { url = it } + }, + ) { inner -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(inner) + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text("Video URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + trailingIcon = { + IconButton(onClick = { + scope.launch { + clipboard.getClipEntry() + ?.clipData + ?.takeIf { it.itemCount > 0 } + ?.getItemAt(0) + ?.coerceToText(ctx) + ?.toString() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { url = it } + } + }) { + Icon(Icons.Filled.ContentPaste, contentDescription = "Paste") + } + }, + ) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + val entries = FormatChoice.entries + entries.forEachIndexed { idx, choice -> + SegmentedButton( + selected = format == choice, + onClick = { vm.setFormat(choice) }, + shape = SegmentedButtonDefaults.itemShape(idx, entries.size), + ) { + Text(if (choice == FormatChoice.Video) "Video" else "Audio") } - }) { - Icon(Icons.Filled.ContentPaste, contentDescription = "Paste") - } - }, - ) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - val entries = FormatChoice.entries - entries.forEachIndexed { idx, choice -> - SegmentedButton( - selected = format == choice, - onClick = { vm.setFormat(choice) }, - shape = SegmentedButtonDefaults.itemShape(idx, entries.size), - ) { - Text(if (choice == FormatChoice.Video) "Video" else "Audio") } } - } - Button( - onClick = { - vm.enqueue(url) - url = "" - }, - enabled = url.isNotBlank(), - modifier = Modifier.fillMaxWidth(), - ) { Text("Download") } - - if (items.isEmpty()) { - Text( - "Paste a link, tap Download. Up to 3 downloads run at once; swipe to remove.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - Row( + Button( + onClick = { + vm.enqueue(url) + url = "" + }, + enabled = url.isNotBlank(), modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { + ) { Text("Download") } + + if (items.isEmpty()) { Text( - "Downloads", - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.weight(1f), + "Paste a link, tap Download. Up to 3 downloads run at once; swipe to remove.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (hasFinished) { - TextButton(onClick = { vm.clearFinished() }) { - Text("Clear finished") + } else { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Downloads", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + if (hasFinished) { + TextButton(onClick = { vm.clearFinished() }) { + Text("Clear finished") + } } } - } - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(items, key = { it.id }) { item -> - DownloadItemCard( - item = item, - onPause = { vm.pause(item.id) }, - onResume = { vm.resume(item.id) }, - onRequestRemove = { pendingRemoval = item }, - onSignIn = { - vm.rememberPendingSignIn(item.id) - signInLauncher.launch(Intent(ctx, YouTubeSignInActivity::class.java)) - }, - onUpdateEngine = { vm.updateEngineAndRetry(item.id) }, - ) + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items, key = { it.id }) { item -> + DownloadItemCard( + item = item, + onPause = { vm.pause(item.id) }, + onResume = { vm.resume(item.id) }, + onRequestRemove = { pendingRemoval = item }, + onSignIn = { + vm.rememberPendingSignIn(item.id) + signInLauncher.launch(Intent(ctx, YouTubeSignInActivity::class.java)) + }, + onUpdateEngine = { vm.updateEngineAndRetry(item.id) }, + ) + } } } } diff --git a/android/app/src/main/java/com/patron/snaglite/ui/SettingsScreen.kt b/android/app/src/main/java/com/patron/snaglite/ui/SettingsScreen.kt index 16c63a6..4c0d17d 100644 --- a/android/app/src/main/java/com/patron/snaglite/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/patron/snaglite/ui/SettingsScreen.kt @@ -1,6 +1,5 @@ package com.patron.snaglite.ui -import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -19,6 +18,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -42,95 +42,84 @@ fun SettingsScreen( ) { val ctx = LocalContext.current val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } var updating by remember { mutableStateOf(false) } var deleteFileOnRemove by remember { mutableStateOf(SnagLitePrefs.deleteFileOnRemove(ctx)) } - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { + SnagLiteScaffold( + title = "Settings", + snackbarHostState = snackbarHostState, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } - Text("Settings", style = MaterialTheme.typography.headlineSmall) - } - - Text( - "Downloads", - style = MaterialTheme.typography.titleMedium, - ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + }, + ) { inner -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(inner) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Column(modifier = Modifier.weight(1f)) { - Text( - "Delete file from device when removing", - style = MaterialTheme.typography.bodyMedium, - ) - Text( - "Pre-checks the box in the swipe-to-remove dialog. You can still flip it per-download.", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + Text("Downloads", style = MaterialTheme.typography.titleMedium) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Delete file from device when removing", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + "Pre-checks the box in the swipe-to-remove dialog. You can still flip it per-download.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = deleteFileOnRemove, + onCheckedChange = { + deleteFileOnRemove = it + SnagLitePrefs.setDeleteFileOnRemove(ctx, it) + }, ) } - Switch( - checked = deleteFileOnRemove, - onCheckedChange = { - deleteFileOnRemove = it - SnagLitePrefs.setDeleteFileOnRemove(ctx, it) - }, - ) - } - HorizontalDivider() + HorizontalDivider() - Text( - "Maintenance", - style = MaterialTheme.typography.titleMedium, - ) - Button( - onClick = { - if (updating) return@Button - updating = true - scope.launch { - val ok = YouTubeUpdater.updateNow(ctx) - updating = false - Toast.makeText( - ctx, - if (ok) "Download engine updated." else "Could not update the download engine.", - Toast.LENGTH_SHORT, - ).show() - } - }, - enabled = !updating, - modifier = Modifier.fillMaxWidth(), - ) { Text(if (updating) "Updating…" else "Update download engine") } + Text("Maintenance", style = MaterialTheme.typography.titleMedium) + Button( + onClick = { + if (updating) return@Button + updating = true + scope.launch { + val ok = YouTubeUpdater.updateNow(ctx) + updating = false + snackbarHostState.showSnackbar( + if (ok) "Download engine updated." + else "Could not update the download engine.", + ) + } + }, + enabled = !updating, + modifier = Modifier.fillMaxWidth(), + ) { Text(if (updating) "Updating…" else "Update download engine") } - OutlinedButton( - onClick = onReRunSetup, - modifier = Modifier.fillMaxWidth(), - ) { Text("Re-run initial setup") } + OutlinedButton( + onClick = onReRunSetup, + modifier = Modifier.fillMaxWidth(), + ) { Text("Re-run initial setup") } - HorizontalDivider() + HorizontalDivider() - Text( - "About", - style = MaterialTheme.typography.titleMedium, - ) - Text( - "SnagLite — Version 1.0", - style = MaterialTheme.typography.bodyMedium, - ) - Text( - "Developed by Paolo Patron.", - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(Modifier.height(8.dp)) + Text("About", style = MaterialTheme.typography.titleMedium) + Text("SnagLite — Version 1.0", style = MaterialTheme.typography.bodyMedium) + Text("Developed by Paolo Patron.", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(8.dp)) + } } } diff --git a/android/app/src/main/java/com/patron/snaglite/ui/SetupScreen.kt b/android/app/src/main/java/com/patron/snaglite/ui/SetupScreen.kt index d3c41bf..5f0347d 100644 --- a/android/app/src/main/java/com/patron/snaglite/ui/SetupScreen.kt +++ b/android/app/src/main/java/com/patron/snaglite/ui/SetupScreen.kt @@ -36,20 +36,22 @@ fun SetupScreen( if (state.finished) onDone() } - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - Text("Welcome to SnagLite", style = MaterialTheme.typography.headlineMedium) - Text( - "Initial setup runs once and takes about 10 to 20 seconds.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - state.steps.forEach { step -> StepRow(step) } + SnagLiteScaffold(title = "Welcome to SnagLite") { inner -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(inner) + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + "Initial setup runs once and takes about 10 to 20 seconds.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + state.steps.forEach { step -> StepRow(step) } + } } } } diff --git a/android/app/src/main/java/com/patron/snaglite/ui/SnagLiteScaffold.kt b/android/app/src/main/java/com/patron/snaglite/ui/SnagLiteScaffold.kt new file mode 100644 index 0000000..c728266 --- /dev/null +++ b/android/app/src/main/java/com/patron/snaglite/ui/SnagLiteScaffold.kt @@ -0,0 +1,59 @@ +package com.patron.snaglite.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll + +/** + * The one Scaffold in the app. Every screen goes through here so that window + * insets, the top bar, and the snackbar host are configured in exactly one + * place — which is what makes the targetSdk 36 edge-to-edge enforcement a + * one-line change rather than a per-screen audit. + * + * [content] receives the Scaffold's PaddingValues and MUST apply them, either + * as `Modifier.padding(inner)` or as a LazyColumn `contentPadding`. Dropping + * them puts content under the top bar and under the system bars. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SnagLiteScaffold( + title: String, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + content: @Composable (PaddingValues) -> Unit, +) { + val appBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(appBarState) + + Scaffold( + modifier = modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.background, + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = navigationIcon, + actions = actions, + scrollBehavior = scrollBehavior, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + content = content, + ) +} From d4d2d0584f3e8b7cb647e16d1a5516e8ef2bd92a Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:20:37 +0800 Subject: [PATCH 14/45] Target Android 16 (API 36) with edge-to-edge and predictive back Google Play requires targetSdk 36 for new apps and updates as of August 31 2026. compileSdk was already 36 (raised in the library bump to clear AAR-metadata floors); this moves targetSdk, which is the half that changes runtime behaviour. targetSdk 35+ makes edge-to-edge mandatory on Android 15+, so the Scaffold now consumes WindowInsets.safeDrawing and MainActivity calls enableEdgeToEdge() to give API 26-34 the same layout. Predictive back is on by default at targetSdk 36; no manifest flag needed. minSdk stays at 26. --- README.md | 6 +++++- android/app/src/main/AndroidManifest.xml | 2 +- .../app/src/main/java/com/patron/snaglite/MainActivity.kt | 5 +++++ .../main/java/com/patron/snaglite/ui/SnagLiteScaffold.kt | 3 +++ android/gradle/libs.versions.toml | 2 +- 5 files changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2aa2f50..a291288 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,10 @@ snaglite --no-aria2 # use yt-dlp's native downloader instea Kotlin + Jetpack Compose APK. Wraps the same `yt-dlp` + `ffmpeg` + `aria2c` engine via the [youtubedl-android](https://github.com/yausername/youtubedl-android) library, with native resolvers bolted on for embed hosts the generic extractor can't crack. +**Android requirements:** Android 8.0 (API 26) or newer. Built against +Android 16 (API 36), so the app draws edge-to-edge behind the status and +navigation bars and supports the predictive back gesture on Android 14+. + ### What it does - Paste any video URL, tap **Download** — saves a merged `.mp4` (or `.m4a` audio-only) to `Movies/SnagLite` (or `Music/SnagLite`) via scoped-storage MediaStore. @@ -217,7 +221,7 @@ The release build is signed with a release keystore if four env vars are set; ot Generate a keystore once with `keytool -genkeypair -v -keystore $HOME\snaglite-release.jks -keyalg RSA -keysize 2048 -validity 36500 -alias snaglite`. -Requires JDK 17 + Android SDK (API 34). +Requires JDK 17 + Android SDK (API 36). ### Settings diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4aa1364..47def17 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -25,7 +25,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.SnagLite" - tools:targetApi="34"> + tools:targetApi="36"> Date: Mon, 3 Aug 2026 10:29:32 +0800 Subject: [PATCH 15/45] Add repo-wide MSBuild props and central package versions Language level, nullable, analyzers and deterministic-build settings move to Directory.Build.props so a second project inherits them. Package versions move to Directory.Packages.props, which is also what Dependabot's nuget ecosystem reads. Analyzers are on at latest-recommended but warnings are not errors: there is no test suite here, so an analyzer-driven build break would block work with nothing to justify it. --- Directory.Build.props | 28 ++++++++++++++++++++++++++++ Directory.Packages.props | 14 ++++++++++++++ src/SnagLite/SnagLite.csproj | 9 +++------ 3 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..0214cd7 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,28 @@ + + + + latest + enable + enable + true + + + + + true + latest-recommended + false + + + + + true + true + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..b6a0de8 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,14 @@ + + + + true + true + + + + + + + + + diff --git a/src/SnagLite/SnagLite.csproj b/src/SnagLite/SnagLite.csproj index 3e56fb7..818a2e9 100644 --- a/src/SnagLite/SnagLite.csproj +++ b/src/SnagLite/SnagLite.csproj @@ -3,11 +3,8 @@ Exe net10.0 - enable - enable snaglite SnagLite - true true true win-x64 @@ -19,9 +16,9 @@ - - - + + + From dc51fa5113a6b227646ab6482ce4b359bf608132 Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:33:23 +0800 Subject: [PATCH 16/45] Watch NuGet, Gradle and Actions dependencies with Dependabot Nothing here noticed when a dependency went stale, which is how the Android toolchain reached a two-year lag. AndroidX and Compose updates are grouped so they land as one PR rather than fifteen. youtubedl-android is excluded on purpose: bumping it changes download behaviour and needs the manual URL matrix, not an automated PR. --- .github/dependabot.yml | 44 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d5edce3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,44 @@ +version: 2 + +updates: + # .NET CLI — versions live in /Directory.Packages.props + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + commit-message: + prefix: "deps" + + # Android — versions live in /android/gradle/libs.versions.toml + - package-ecosystem: "gradle" + directory: "/android" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + commit-message: + prefix: "deps" + groups: + androidx: + patterns: + - "androidx.*" + compose: + patterns: + - "androidx.compose*" + ignore: + # Upgrading the download engine changes download behaviour, not build + # plumbing. It needs the manual URL matrix in PRIVATE_NOTES.local.md, + # so it does not belong in an automated PR. + - dependency-name: "io.github.junkfood02.youtubedl-android:*" + + # Workflow actions in .github/workflows/ + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 3 + commit-message: + prefix: "ci" From d9b4ff746c44e1338bddfde2768ff325613b75da Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:46:23 +0800 Subject: [PATCH 17/45] Reorder Dependabot groups so the Compose group is reachable Dependabot assigns a dependency to the first matching group, not every matching group. With androidx (androidx.*) listed first it swallowed every androidx.compose* artifact and the compose group never received a member, so the config did not do what its own commit message claimed. Narrow pattern now comes first. --- .github/dependabot.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d5edce3..68bf7be 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,13 +20,17 @@ updates: open-pull-requests-limit: 5 commit-message: prefix: "deps" + # Dependabot assigns a dependency to the FIRST group whose pattern matches, + # not to every group it matches. `compose` must therefore come before + # `androidx` — the other way round, "androidx.*" swallows every Compose + # artifact and the compose group never receives a member. groups: - androidx: - patterns: - - "androidx.*" compose: patterns: - "androidx.compose*" + androidx: + patterns: + - "androidx.*" ignore: # Upgrading the download engine changes download behaviour, not build # plumbing. It needs the manual URL matrix in PRIVATE_NOTES.local.md, From 6accea41abdea2dd2f5c27cf4302a96b1d3bb83b Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:52:14 +0800 Subject: [PATCH 18/45] Update agent notes for the 2026 toolchain Records the version catalog as the single source of Android versions, why AGP 9 is deliberately skipped, the backup-exclusion invariant for the cookie jar, the one-Scaffold rule, and the WorkManager gap as known-and-out-of-scope rather than undiscovered. --- CLAUDE.md | 73 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ca40019..da7d8d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,8 @@ SnagLite/ ├── SnagLite.slnx ├── README.md / CLAUDE.md / PRIVATE_NOTES.local.md (gitignored) +├── Directory.Build.props / Directory.Packages.props # .NET repo-wide props + central package versions +├── .github/dependabot.yml # weekly nuget / gradle / actions updates ├── publish/ # dotnet publish output → snaglite.exe ├── src/SnagLite/ # .NET 10 console (AssemblyName=snaglite, RootNamespace=SnagLite) │ ├── SnagLite.csproj # net10.0, single-file self-contained @@ -18,29 +20,56 @@ SnagLite/ │ ├── Config/AppConfig.cs # JSON config at %APPDATA%\SnagLite\config.json │ └── hosts.local.txt # gitignored, embedded as resource if present └── android/ # Kotlin/Compose APK, applicationId com.patron.snaglite - └── app/src/main/ - ├── AndroidManifest.xml # FOREGROUND_SERVICE_DATA_SYNC, WAKE_LOCK, ACCESS_WIFI_STATE, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - ├── assets/hosts.local.txt # gitignored, loaded by IframeUnwrapper.init - └── java/com/patron/snaglite/ - ├── SnagLiteApplication.kt # channel "snaglite_downloads", media migration on first run - ├── MainActivity.kt - ├── download/ # DownloadController, Downloader (yt-dlp path), DirectDownloader (resolver path), - │ # MediaSink (publishes to Movies/SnagLite or Music/SnagLite via MediaStore), - │ # MetadataFetcher, SnagLitePrefs, DownloadItem, resolvers/ - ├── ui/ # Compose: MainScreen, SettingsScreen, SetupScreen, DownloadItemCard, BatteryOptDialog, Theme (SnagLiteTheme), UpdateGate - ├── yt/ # YouTubeHost, YouTubeArgsInjector, YouTubeBootstrapper (WebView visitor_data harvest), - │ # YouTubePlaylist, YouTubePrefs ("snaglite_yt"), YouTubeUpdater, EngineUpdateChecker, CookieStore - ├── service/DownloadService.kt # foreground service, dataSync type - └── webview/YouTubeSignInActivity.kt + ├── app/src/main/ + │ ├── AndroidManifest.xml # FOREGROUND_SERVICE_DATA_SYNC, WAKE_LOCK, ACCESS_WIFI_STATE, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + │ ├── assets/hosts.local.txt # gitignored, loaded by IframeUnwrapper.init + │ ├── res/xml/ # backup_rules.xml + data_extraction_rules.xml (cookie-jar exclusions) + │ └── java/com/patron/snaglite/ + │ ├── SnagLiteApplication.kt # channel "snaglite_downloads", media migration on first run + │ ├── MainActivity.kt + │ ├── download/ # DownloadController, Downloader (yt-dlp path), DirectDownloader (resolver path), + │ │ # MediaSink (publishes to Movies/SnagLite or Music/SnagLite via MediaStore), + │ │ # MetadataFetcher, SnagLitePrefs, DownloadItem, resolvers/ + │ ├── ui/ # Compose: SnagLiteScaffold (the one Scaffold), MainScreen, SettingsScreen, + │ │ # SetupScreen, DownloadItemCard, BatteryOptDialog, Theme (SnagLiteTheme), UpdateGate + │ ├── yt/ # YouTubeHost, YouTubeArgsInjector, YouTubeBootstrapper (WebView visitor_data harvest), + │ │ # YouTubePlaylist, YouTubePrefs ("snaglite_yt"), YouTubeUpdater, EngineUpdateChecker, CookieStore + │ ├── service/DownloadService.kt # foreground service, dataSync type + │ └── webview/YouTubeSignInActivity.kt + └── gradle/libs.versions.toml # version catalog — every Android dependency + plugin version ``` ## Build / run ```bash -dotnet publish src/SnagLite/SnagLite.csproj -c Release -r win-x64 --self-contained -o publish # → publish/snaglite.exe (~37 MB) +dotnet publish src/SnagLite/SnagLite.csproj -c Release -r win-x64 --self-contained -o publish # → publish/snaglite.exe (~42.6 MB) cd android && .\gradlew.bat :app:assembleRelease # → app-{arm64-v8a,armeabi-v7a,x86_64,universal}-release.apk ``` +## Toolchain versions + +All Android versions live in `android/gradle/libs.versions.toml` — never hardcode one in a `build.gradle.kts`. + +| | Version | Notes | +|---|---|---| +| AGP | 8.13.2 | Max supported API is 36.1 | +| Gradle | 8.13 | AGP 8.13.2's minimum and default | +| Kotlin | 2.3.20 | K2. Compose compiler is `org.jetbrains.kotlin.plugin.compose`, versioned *as* Kotlin — there is no `composeOptions` block | +| compileSdk / targetSdk | 36 | Play requires 36 for new apps and updates as of 2026-08-31 | +| minSdk | 26 | Do not raise | +| JDK | 17 | | +| Compose BOM | 2026.06.01 | Material3 1.4.0 | +| .NET | net10.0 | Current LTS | + +**AGP 9.x is deliberately not adopted.** It removes the `org.jetbrains.kotlin.android` plugin in favour of built-in Kotlin *and* flips `android.r8.strictFullModeForKeepRules` to `true`, under which `-keep class A` stops implying A's default constructor. Given this project's history of an R8 launch crash from exactly that (commons-compress `AsiExtraField`), those two migrations must not land together. See the R8 notes below. + +**Three libraries are deliberately pinned below their newest release** — do not bump them without solving the blocker first: +- `androidx.core:core-ktx` **1.17.0** — 1.19.0 requires compileSdk 37 + AGP 9.1; 1.18.0 requires compileSdk 36.1. +- `androidx.lifecycle` **2.10.0** — 2.11.0 requires compileSdk 37 + AGP 9.2. +- `io.coil-kt.coil3` **3.4.0** — 3.5.0 is built with Kotlin 2.4.0, whose stdlib metadata a 2.3.20 compiler cannot read; the symptom is a flood of bogus "unresolved reference" errors on ordinary stdlib calls in files that never touch Coil. + +**Kotlin 2.3.20 makes `kotlinOptions { jvmTarget = "17" }` a hard error, not a deprecation warning.** The replacement is a top-level block in `android/app/build.gradle.kts`: `kotlin { compilerOptions { jvmTarget = JvmTarget.fromTarget("17") } }`. + ## CI Three GitHub Actions workflows live under `.github/workflows/`: @@ -60,6 +89,8 @@ Release signing is wired into `release.yml`: four GitHub Secrets — `SNAGLITE_K | CLI | `%LOCALAPPDATA%\SnagLite\bin\` (auto-installed yt-dlp / ffmpeg / aria2c) | `%APPDATA%\SnagLite\config.json` | `%USERPROFILE%\Videos\SnagLite\` | | Android | extracted from APK assets to internal storage | `SharedPreferences("snaglite_yt")` + `SharedPreferences("snaglite_app")` | `Movies/SnagLite` (video) / `Music/SnagLite` (audio) | +`filesDir/yt-cookies.txt`, the WebView cookie database under `app_webview/`, and the `snaglite_yt` preference file are excluded from Android Auto Backup and from device-to-device transfer via `res/xml/backup_rules.xml` (API ≤ 30) and `res/xml/data_extraction_rules.xml` (API 31+). Both files must be kept in sync — a cookie surface added to one and not the other leaks on half the supported API range. + First-run migration is automatic on both platforms: - CLI: `Paths.MigrateLegacyDataDirs()` (called from `Program.cs`) moves `Snagger` → `Snag` → `SnagLite` for `%LOCALAPPDATA%`, `%APPDATA%`, and `%USERPROFILE%\Videos`. Idempotent — skips when target exists. - Android: `MediaSink.migrateLegacyPublishedFiles()` (called from `SnagLiteApplication.onCreate`, gated by `SnagLitePrefs.K_MEDIA_MIGRATED`) re-writes MediaStore `RELATIVE_PATH` for `Movies/Snag/` → `Movies/SnagLite/` and `Music/Snag/` → `Music/SnagLite/`. Best-effort; rows owned by the old `com.patron.snag` install may not update (harmless). @@ -72,6 +103,8 @@ First-run migration is automatic on both platforms: - **Generic-extractor retry.** On `Unsupported URL`, `DownloadCommand` re-runs once with `--force-generic-extractor` + inferred `Referer`. A short pre-known list of wrapper-host iframes is unwrapped upfront via `IframeUnwrapper`; the list is loaded at process start from gitignored `hosts.local.txt` (CLI: embedded resource; Android: APK asset). Missing file → empty list → feature silently disabled, normal extractor + generic fallback still handle everything. - **Android background reliability.** `DownloadController.ensureLocks()` acquires a `PARTIAL_WAKE_LOCK` (2-hour cap) + `WIFI_MODE_FULL_HIGH_PERF` `WifiLock` while any item is active; both released by `releaseLocks()` on drain. `BatteryOptDialog` on first launch (gated by `SnagLitePrefs.battOptDontAsk`) fires `ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` to defeat doze on Android 6+. Foreground service type is `dataSync`. - **Thumbnail prefetch.** `MetadataFetcher` runs `yt-dlp --dump-json --skip-download --no-playlist --no-warnings`; parses `title`, best `thumbnail`, `duration`, `uploader`/`channel`, `filesize`/`filesize_approx`. `DownloadController.enqueue` fires this on `applicationScope` as fire-and-forget — download isn't blocked. `DownloadItemCard` renders via Coil 3 `AsyncImage` (`coil-network-okhttp:3.0.4`), falls back to a `PlayCircle` icon. Translucent scrim + `CircularProgressIndicator` overlays while running. +- **One `Scaffold`, in `SnagLiteScaffold.kt`.** Every screen renders through it. It owns the `TopAppBar`, the `SnackbarHost`, and `contentWindowInsets = WindowInsets.safeDrawing`, and hands its `PaddingValues` to the screen — which must apply them. Do not add a second `Scaffold` and do not apply inset padding anywhere else; `targetSdk 36` makes edge-to-edge mandatory, and having one inset owner is what keeps that a configuration detail rather than a per-screen audit. User feedback goes through the snackbar host, never `Toast`. +- **DayNight window theme + splash screen.** `MainActivity` calls `installSplashScreen()` before `super.onCreate()`, then `enableEdgeToEdge()`. `values/themes.xml` (API 26–28) and `values-night/` cover pre-29 dark mode; `values-v29/themes.xml` parents on `android:Theme.DeviceDefault.DayNight` so the platform theme itself follows system dark mode. Note: the framework has no `android:Theme.DeviceDefault.DayNight.NoActionBar` at any API level — `values-v29/themes.xml` reproduces `NoActionBar` manually with `windowActionBar=false` + `windowNoTitle=true`. The window background must track dark mode via `@color/snaglite_window_background` or a cold launch flashes the wrong color before Compose paints. ## Android release signing @@ -97,12 +130,22 @@ No automated tests. Manual matrix (concrete URLs in `PRIVATE_NOTES.local.md`): 5. `--audio-only`, `-o`, `snaglite update`. 6. Build with `hosts.local.txt` absent — wrapper-host URL falls through to yt-dlp's generic extractor cleanly (no crash, no unwrap). 7. Android: install on top of a pre-existing `com.patron.snag` install — confirm `Movies/Snag/*` migrates to `Movies/SnagLite/` on first launch (best-effort). +8. Rotate the device mid-download — the list, its progress, and in-flight setup steps must survive. +9. Back gesture from Settings returns to the list; back again exits. +10. Cold launch in dark mode shows no white flash; cold launch in light mode shows no dark flash. +11. Edge-to-edge: app bar background reaches the top edge but its title clears the status bar; the Download button clears the navigation bar. Check in both gesture and 3-button navigation. + +Items 8–11 were added by the 2026 toolchain modernization branch and are **unverified on hardware** — the test device was removed partway through that work. The edge-to-edge, `SnagLiteScaffold`, and splash-screen changes were verified by build and static APK inspection only; run the full matrix on a real device before treating them as confirmed. ## Deliberately out of scope - Cookies / login flows (no creds, per requirement) — except the YouTube WebView sign-in fallback on Android. - Playlists on CLI (Android supports YouTube playlists via `YouTubePlaylist.enumerate`). - Batch files, subtitles, cross-platform CLI. +- AGP 9.x / built-in Kotlin (see Toolchain versions for why). +- WorkManager migration for the download queue. The queue lives only in an in-memory `MutableStateFlow` in `DownloadController`, so it does not survive process death, and `pumpQueue` can start a foreground service while the app is backgrounded — which Android 12+ rejects with `ForegroundServiceStartNotAllowedException`. Both are real, both are known, and both need their own plan. +- Adaptive / large-screen layouts. The list is single-pane at every window size. +- Automated tests of any kind. --- From 3319055d2b43a4135dd9fb188f52ac6471ee52d9 Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:24:10 +0800 Subject: [PATCH 19/45] Make "Re-run initial setup" actually re-run setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that were individually correct combined into a dead button. Scoping SetupViewModel with viewModel() retained it in the Activity's ViewModelStore, so it now outlives the composition; the `started` guard added to stop a configuration change re-entering a run then also blocked the only other way SetupScreen is composed twice — Settings → "Re-run initial setup". The result was a one-frame flicker back to the download list, and because start() bailed before reaching setSetupComplete(true), the pref stayed false and the setup screen reappeared unrequested on the next cold launch. start() now treats an already-finished run as a re-run request: reset the step list to Pending, clear `started`, and fall through. The in-progress case the guard exists for is unchanged. SetupScreen also had to stop keying its completion effect on `state.finished`. collectAsState updates its State a dispatch after the StateFlow changes, so an effect reading the snapshot would see the stale `true` on the first frame of the re-run and call onDone() before the reset landed. Awaiting the flow directly with `first { it.finished }` reads the value start() has already reset. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016HgzaN91n4oYiB7tRHYiCG --- .../java/com/patron/snaglite/ui/SetupScreen.kt | 15 ++++++++++++--- .../com/patron/snaglite/ui/SetupViewModel.kt | 18 ++++++++++++++++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/com/patron/snaglite/ui/SetupScreen.kt b/android/app/src/main/java/com/patron/snaglite/ui/SetupScreen.kt index 5f0347d..e5c8df2 100644 --- a/android/app/src/main/java/com/patron/snaglite/ui/SetupScreen.kt +++ b/android/app/src/main/java/com/patron/snaglite/ui/SetupScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.first @Composable fun SetupScreen( @@ -31,9 +32,17 @@ fun SetupScreen( ) { val state by vm.state.collectAsState() - LaunchedEffect(Unit) { vm.start(requestNotifPermission) } - LaunchedEffect(state.finished) { - if (state.finished) onDone() + // Wait on the flow rather than on `state.finished`. When the user picks + // Settings → "Re-run initial setup" the retained ViewModel still holds a + // finished state, and collectAsState only clears it a dispatch later — a + // second LaunchedEffect keyed on `state.finished` would read the stale + // `true` on the very first frame and call onDone() before the re-run had a + // chance to show. `first { it.finished }` reads the StateFlow directly, + // after start() has already reset it. + LaunchedEffect(Unit) { + vm.start(requestNotifPermission) + vm.state.first { it.finished } + onDone() } SnagLiteScaffold(title = "Welcome to SnagLite") { inner -> diff --git a/android/app/src/main/java/com/patron/snaglite/ui/SetupViewModel.kt b/android/app/src/main/java/com/patron/snaglite/ui/SetupViewModel.kt index a3a0c92..d9e33d3 100644 --- a/android/app/src/main/java/com/patron/snaglite/ui/SetupViewModel.kt +++ b/android/app/src/main/java/com/patron/snaglite/ui/SetupViewModel.kt @@ -33,8 +33,9 @@ class SetupViewModel(private val app: Application) : AndroidViewModel(app) { /** * The ViewModel now outlives the composition, so `LaunchedEffect(Unit)` in - * SetupScreen re-fires on every configuration change. Setup must run once - * per ViewModel instance, not once per composition. + * SetupScreen re-fires on every configuration change. Guards against a + * configuration change re-entering a run that is still in progress; see + * [start] for why a *finished* run is explicitly not guarded. */ private var started = false @@ -49,6 +50,19 @@ class SetupViewModel(private val app: Application) : AndroidViewModel(app) { val state: StateFlow = _state.asStateFlow() fun start(requestNotifPermission: suspend () -> Unit) { + // A run that already finished means SetupScreen is being composed a + // second time, which only happens when the user picks Settings → + // "Re-run initial setup". Reset instead of early-returning: otherwise + // the retained ViewModel still reports finished == true, SetupScreen's + // LaunchedEffect calls onDone() on its first frame, and setup never + // re-runs — leaving isSetupComplete false so the screen reappears on + // the next cold launch. The `started` guard only needs to hold while a + // run is still in flight, which is the configuration-change case it + // was added for. + if (started && _state.value.finished) { + started = false + _state.value = SetupUiState(initial, finished = false) + } if (started) return started = true viewModelScope.launch { From 655f09e26f5010694ed3e9e4fd1356c1602f62ee Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:24:19 +0800 Subject: [PATCH 20/45] Inset the YouTube sign-in screen under the system bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YouTubeSignInActivity is the app's only View-based screen, so it was missed when the branch moved everything else to edge-to-edge Compose scaffolds. With targetSdk 36, Android 15+ renders every window in the app edge-to-edge and ignores setDecorFitsSystemWindows, so the header — "Sign in to YouTube" and the Close button, with only 24 raw pixels of padding — drew behind the status bar, and the bottom of Google's login page, where its Sign in / Next button lives, drew behind the navigation bar. Apply systemBars() | displayCutout() insets as padding on the root container, added to whatever padding it already has so any design padding survives. The insets are returned unconsumed: nothing below the root applies system-bar padding, so there is no double-application to avoid, and CONSUMED would also swallow Type.ime() and stop the WebView reacting to the keyboard the login form needs. On API 26-34 the decor still fits system windows, so the listener sees zeroed insets and the layout is unchanged there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016HgzaN91n4oYiB7tRHYiCG --- .../snaglite/webview/YouTubeSignInActivity.kt | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/android/app/src/main/java/com/patron/snaglite/webview/YouTubeSignInActivity.kt b/android/app/src/main/java/com/patron/snaglite/webview/YouTubeSignInActivity.kt index 4078978..dfd904b 100644 --- a/android/app/src/main/java/com/patron/snaglite/webview/YouTubeSignInActivity.kt +++ b/android/app/src/main/java/com/patron/snaglite/webview/YouTubeSignInActivity.kt @@ -12,6 +12,8 @@ import android.widget.Button import android.widget.LinearLayout import android.widget.TextView import androidx.activity.ComponentActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import com.patron.snaglite.yt.CookieStore import com.patron.snaglite.yt.YouTubePrefs @@ -73,6 +75,36 @@ class YouTubeSignInActivity : ComponentActivity() { container.addView(web) setContentView(container) + // This is the app's only View-based screen. With targetSdk 36, Android + // 15+ draws every window edge-to-edge and ignores + // setDecorFitsSystemWindows, so without this the header label and the + // Close button sit under the status bar and the bottom of the login + // page — where Google puts its Sign in / Next button — sits under the + // navigation bar. Insets are added to the root's own padding so any + // design padding survives; the header keeps its 24px separately. + val baseLeft = container.paddingLeft + val baseTop = container.paddingTop + val baseRight = container.paddingRight + val baseBottom = container.paddingBottom + ViewCompat.setOnApplyWindowInsetsListener(container) { v, insets -> + val bars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), + ) + v.setPadding( + baseLeft + bars.left, + baseTop + bars.top, + baseRight + bars.right, + baseBottom + bars.bottom, + ) + // Returned unconsumed on purpose. The only children are a plain + // LinearLayout header, which ignores insets entirely, and the + // WebView — so there is nothing here that would double-apply the + // system-bar padding, while WindowInsetsCompat.CONSUMED would also + // swallow Type.ime() and stop the WebView reacting to the keyboard + // it needs for the login form. + insets + } + val cm = CookieManager.getInstance() cm.setAcceptCookie(true) cm.setAcceptThirdPartyCookies(web, true) From eaa200fb7fd206708e7b4617f736850b88dbb24f Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:24:28 +0800 Subject: [PATCH 21/45] Pair the API 26-28 theme parent with its day-mode window background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit values/themes.xml parented Theme.SnagLite on android:Theme.Material. NoActionBar — the dark platform theme — on the reasoning that dark is the safer choice for the few frames before Compose paints. But the same style pins android:windowBackground to @color/snaglite_window_background, which in this resource folder is the day-mode #FFFBFE. The platform theme's text colours and the window background therefore disagreed on API 26-28 in light mode. Compose screens never noticed, because they take their colours from MaterialTheme. YouTubeSignInActivity is a plain View hierarchy whose TextView and Button resolve ?android:textColorPrimary from this theme, so its label and Close button rendered near-white on near-white. Switch to android:Theme.Material.Light.NoActionBar. There is no DayNight platform parent before API 29, so night-mode correctness comes from values-night/colors.xml supplying the dark background, and API 29+ is unaffected either way because values-v29/themes.xml overrides the whole style with a real DayNight parent. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016HgzaN91n4oYiB7tRHYiCG --- android/app/src/main/res/values/themes.xml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index a4d13b5..31fccc6 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -7,8 +7,18 @@ cold launch flashes white before Compose paints its dark scheme; that comes from @color/snaglite_window_background resolving through values-night/colors.xml, which works on every supported API level. + + The parent is the *Light* platform theme because a fixed parent has to + agree with the windowBackground it is paired with in this same resource + folder, and values/colors.xml is the day-mode one (#FFFBFE). There is no + DayNight platform parent before API 29, so night-mode correctness comes + from the -night colour qualifier rather than from the parent. Compose + screens take their colours from MaterialTheme and are unaffected either + way, but YouTubeSignInActivity is a plain View hierarchy whose TextView + and Button resolve ?android:textColorPrimary from this theme — a dark + parent here renders them near-white on a near-white window. --> - + + diff --git a/android/app/src/main/res/values-night/themes.xml b/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..35d84d6 --- /dev/null +++ b/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index 31fccc6..04114e6 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -11,12 +11,15 @@ The parent is the *Light* platform theme because a fixed parent has to agree with the windowBackground it is paired with in this same resource folder, and values/colors.xml is the day-mode one (#FFFBFE). There is no - DayNight platform parent before API 29, so night-mode correctness comes - from the -night colour qualifier rather than from the parent. Compose - screens take their colours from MaterialTheme and are unaffected either - way, but YouTubeSignInActivity is a plain View hierarchy whose TextView - and Button resolve ?android:textColorPrimary from this theme — a dark - parent here renders them near-white on a near-white window. + DayNight platform parent before API 29, so night mode gets its own fixed + parent in values-night/themes.xml — the colour qualifier alone is not + enough, because it moves windowBackground and never + ?android:textColorPrimary. Compose screens take their colours from + MaterialTheme and are unaffected either way, but YouTubeSignInActivity is + a plain View hierarchy whose TextView and Button resolve + ?android:textColorPrimary from this theme — a dark parent here renders + them near-white on a near-white window, and a light parent under a night + window background renders them near-black on near-black. -->