diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..68bf7be --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,48 @@ +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" + # 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: + 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, + # 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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2b47967 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,165 @@ +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 release 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: 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-release + path: android/app/build/outputs/apk/release/*.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 }}-windows-x64.exe" + for f in dist/android-release/*.apk; do + base=$(basename "$f" .apk) + cp "$f" "release/snaglite-${{ inputs.tag }}-android-${base#app-}.apk" + 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_path: release/BODY.md + generate_release_notes: true + prerelease: ${{ inputs.prerelease }} + files: release/snaglite-* + fail_on_unmatched_files: true diff --git a/CLAUDE.md b/CLAUDE.md index 5708fe7..6ae1e97 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 @@ -15,32 +17,75 @@ SnagLite/ │ ├── Commands/ # DownloadCommand, UpdateCommand, WhereCommand, ConfigCommands │ ├── Services/ # Paths, ToolResolver, YtDlpRunner, ProgressParser, Aria2cDownloader, CurlClient │ ├── Services/Resolvers/ # IResolver + ResolverA/B/C + IframeUnwrapper + ResolverPipeline (see PRIVATE_NOTES.local.md) +│ ├── Services/YouTube/ # YouTubeHost, YouTubeArgsInjector, EngineUpdateChecker (403/sign-in retry hardening, ported from Android) │ ├── 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/, + │ │ # QueueStore (queue mirrored to filesDir/queue.json) + │ ├── 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/DownloadQueueWorker.kt # WorkManager worker: owns the dataSync FGS + + │ │ # aggregate notification, waits for the queue to drain + │ └── 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 | +| WorkManager | 2.10.5 | Held on the 2.10 line to match the `lifecycle` pin — the 2.11 androidx generation is the one that wants compileSdk 37 | +| .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. Observed during this branch's Kotlin 2.2.20 → 2.3.20 migration: 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/`: + +| 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 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 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 | Platform | Binaries | Config / prefs | Default output | @@ -48,6 +93,8 @@ cd android && .\gradlew.bat :app:assembleRelease | 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). @@ -59,13 +106,15 @@ First-run migration is automatic on both platforms: - **One Spectre `Progress` task per URL.** Multi-stage state (download → merge → extract) mutates the task description rather than spawning bars. yt-dlp uses `--progress-template` with a `PROG|` prefix so `ProgressParser` skips other newline output. - **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. +- **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`; version lives in `libs.versions.toml`), 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 -`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 @@ -85,12 +134,41 @@ 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. All four were **run on hardware on 2026-08-03** (TECNO KM7k, Android 15 / API 35, arm64-v8a) against the release APK — R8 and the resource shrinker included — and all four passed, along with the release smoke test, the setup flow, the Snackbar migration, RTL, and an end-to-end download. Three checks remain unverified and are **not** claimed: + +- **API 26–28 base theme** (`values/themes.xml`) and the parked API 26–28 night-mode sign-in header. Invisible on API 29+, which resolves `values-v29/themes.xml` instead. Needs an API 26–28 emulator image. +- **The `dataSync` timeout path** (Android 15's 6h/24h quota). No `adb` command spends the quota; it can only be observed on a genuinely long session. Now handled by WorkManager rather than the old `DownloadService.onTimeout`. +- **Predictive-back cancel** (start the gesture, hold, slide back, release below the commit threshold). `adb shell input swipe` only produces linear gestures, so this needs a human thumb. + +The run turned up one real defect, since fixed: `YouTubeSignInActivity` did not call `enableEdgeToEdge()`, so it kept the platform default system-bar icon appearance and, in **light mode**, drew near-white status-bar icons (and near-white three-button nav-bar icons) on its near-white background. Content insets were never the problem. The activity now makes the same `enableEdgeToEdge()` call `MainActivity` does; re-verified on device across light/dark × gesture/three-button. Every View-based screen added here needs that call — the Compose screens get it from `MainActivity`. + +Reaching that activity for a test is awkward: it is `exported="false"` and `am start` is refused even against a debug build. Flip `exported="true"`, build a throwaway debug APK, verify, then revert and confirm the tree is clean. ## 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~~ — **done**, in two phases. `QueueStore` mirrors the queue to `filesDir/queue.json` on every state change and `DownloadController` restores it synchronously in its constructor, so the list survives process death. Restored in-flight items come back as `Queued` — never `Running`, since no process is behind them — and resume through yt-dlp's `--continue` against the work directory, which is why `workDirFor` had to move off `cacheDir`. Progress percentages are deliberately not persisted. **`Paused` is the one non-terminal state that survives restore as itself**: every other one describes a process that is gone, while `Paused` describes a decision the user made, and silently resuming a download someone deliberately stopped is worst exactly when it matters most — on metered data, which is usually why they stopped it. `DownloadQueueWorker` then replaced `DownloadService`: one worker for the whole queue, holding the `dataSync` FGS and the aggregate notification and waiting for the drain. It does **not** run downloads — those stay as capped coroutines on the application scope, so `MAX_ACTIVE = 3` needs no reimplementation on a WorkManager that has no per-tag parallelism cap, and progress still flows through the existing `StateFlow` with no UI changes. + + **Measured limits — do not promise more than this.** Verified on hardware 2026-08-04: + - **Reboot, after the user unlocks: resumes unattended.** The process restarts on its own with the launcher in the foreground; the app is never opened. + - **Reboot, before unlock: cannot resume, ever.** The device sits in Direct Boot (`RUNNING_LOCKED`) and `/data/user/0/…` is credential-encrypted — that holds `queue.json`, the partials *and WorkManager's own job database*, so there is nothing to reschedule from. Fixing this would mean moving download metadata to device-encrypted storage, outside the user's credential protection. Deliberately not done. + - **`am force-stop`: cannot resume until the app is opened.** It sets `stopped=true` on the package, which blocks every component including WorkManager's jobs. Jobs stay registered; they are simply not allowed to run. + + The worker is enqueued **expedited, with `RUN_AS_NON_EXPEDITED_WORK_REQUEST` as the fallback**. Expedited is what makes the foreground-service start from the background legal — the thing `ForegroundServiceStartNotAllowedException` blocked when `pumpQueue` tried it directly — but the quota is finite per 24 hours, so it is a request and not a guarantee. The fallback is deliberate: a drain that starts late is an inconvenience, one that is dropped is a queue the user has to notice and restart by hand. If the initial `setForeground` fails anyway, `doWork` still calls `pumpNow()` and only *then* returns `Result.retry()`. The pump order matters and is not incidental: gating it on a successful foreground start would mean a device that persistently refuses a background foreground-start never drains its queue at all, which is a regression against the old service — that one pumped regardless and merely logged. A queue draining unprotected is worse than draining protected, and far better than not draining. Mid-stream progress refreshes still swallow their failures; one dropped notification tick is not worth a retry. + + Note the division of labour: `DownloadQueueWorker` owns the notification, `DownloadController.refreshServiceLifecycle` owns only the wake lock / Wi-Fi lock and the guarantee that a worker exists. The controller does **not** build notifications — it did briefly, and the leftover code outlived its callers. + + Still open: `DownloadService.onTimeout`'s replacement path. The Android 15 6h/24h `dataSync` quota applies to a WorkManager foreground worker exactly as it did to the old service — the difference is WorkManager reschedules afterwards rather than dropping the work. +- Adaptive / large-screen layouts. The list is single-pane at every window size. +- Automated tests of any kind. --- diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..88de1e3 --- /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..f2aaa5b --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,14 @@ + + + + true + true + + + + + + + + + diff --git a/README.md b/README.md index 7eebff8..b34f20b 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,13 @@ SnagLite is a tiny video downloader that does the obvious thing well: you give i There are two builds in this repo: - **`snaglite.exe`** — a single-file Windows CLI ([`src/SnagLite/`](src/SnagLite/)). -- **SnagLite Android** — a Kotlin + Jetpack Compose app ([`android/`](android/)) with a foreground download service, a download queue, pause/resume, and a one-time setup screen that bootstraps everything on first launch. +- **SnagLite Android** — a Kotlin + Jetpack Compose app ([`android/`](android/)) with a download queue that survives the app being killed, pause/resume, a WorkManager-backed foreground service, and a one-time setup screen that bootstraps everything on first launch. Both builds share the same philosophy: no accounts, no telemetry, no nags, no PATH wrangling. They support [the ~1800 sites yt-dlp supports](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) out of the box, plus a small generic resolver pipeline that fills in for a few common iframe-wrapper hosts the bundled extractors don't recognise. ## 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 @@ -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. @@ -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`. @@ -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`. @@ -146,11 +147,18 @@ 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. The system back gesture works throughout — Settings backs out +to the download list, and back again leaves the app; Android 14+'s animated +predictive-back preview shows only when leaving the app, not when backing out +of Settings. + ### 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. - **Multi-download queue** — up to 3 downloads run in parallel; extra submissions wait as `Queued` and are promoted automatically as slots free. Each download card shows a 16:9 thumbnail (fetched lazily from yt-dlp metadata) with an overlaid progress ring while running, plus the title, uploader, duration, file size, and a thin linear progress bar with the latest speed/ETA line. Swipe a card to remove it (with an optional delete-from-device checkbox). -- **Pause / resume** — tap the pause icon on any active download. yt-dlp picks up the `.part` file on resume; direct-host downloads from the custom resolver pipeline restart from byte 0 (no per-chunk progress is persisted). +- **Pause / resume** — tap the pause icon on any active download. yt-dlp picks up the `.part` file on resume; direct-host downloads from the custom resolver pipeline restart from byte 0 (no per-chunk progress is persisted). A paused download stays paused even if the app's process is killed — it will not quietly resume behind you on the next launch. - **One-time setup screen** on first launch: 1. Extracts `yt-dlp` / `ffmpeg` / `aria2c` from APK assets to internal storage. 2. Auto-updates `yt-dlp` to latest stable. @@ -158,7 +166,8 @@ Kotlin + Jetpack Compose APK. Wraps the same `yt-dlp` + `ffmpeg` + `aria2c` engi 4. Requests `POST_NOTIFICATIONS` permission. - Subsequent launches: instant. Background re-check of `yt-dlp` weekly. - Live progress bar on the download card (percent + last yt-dlp/aria2c stdout line). -- Foreground service keeps long downloads alive when the app is backgrounded. +- **The queue survives the app being killed.** It is mirrored to disk on every change and restored on the next launch, so a download interrupted by the OS reclaiming memory picks up from its partial file rather than starting over. After a reboot it resumes on its own once you unlock the phone, without the app being opened. Two cases it cannot recover from: a reboot you never unlock after (the queue lives in credential-encrypted storage, which stays locked), and a manual **Force stop** (Android blocks every component of a force-stopped app until you open it again). +- A foreground service — run by WorkManager, so it is rescheduled rather than dropped — keeps long downloads alive when the app is backgrounded, with one notification showing the whole queue's progress. - **Background reliability** — SnagLite holds a `PARTIAL_WAKE_LOCK` so the CPU stays awake while downloads run, and a `WIFI_MODE_FULL_HIGH_PERF` `WifiLock` so the Wi-Fi radio doesn't sleep mid-stream when the screen is off. On first launch the app prompts to add itself to the battery-optimization exemption list — this prevents doze-mode throttling on Android 6+ and is what keeps a long download going while the phone is locked. - No telemetry. No accounts. @@ -170,18 +179,23 @@ 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. **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. -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. +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. -Most public videos work without sign-in. Age-gated, members-only, or aggressively bot-checked videos fall through to step 4. +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 -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 +214,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 | |---|---| @@ -211,7 +225,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 @@ -219,12 +233,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 @@ -235,7 +248,7 @@ Tap the gear icon on the main screen. | Filename | ` [<id>].(mp4|m4a)` (yt-dlp `--restrict-filenames`) | | Downloader (yt-dlp path) | `aria2c -x16 -s16 -k1M` | | Downloader (resolver path) | OkHttp 8-chunk parallel `Range` GETs | -| Cache dir | `<cacheDir>/snaglite-dl/<download-id>/` (per-download subdir; kept across pause/resume, removed when the item is dismissed) | +| Work dir | `<filesDir>/snaglite-dl/<download-id>/` (per-download subdir; kept across pause/resume, removed when the item is dismissed or published). Deliberately not under `cacheDir`, which the OS may reclaim under storage pressure — most likely while the app is backgrounded and against the largest file, i.e. the near-complete download you just switched away from. | | Concurrency | 3 active downloads; extras queued | ### Paths (on-device) @@ -244,6 +257,8 @@ Tap the gear icon on the main screen. |---|---| | Native binaries | `/data/data/com.patron.snaglite/files/` (libpython, ffmpeg, aria2c, yt-dlp.zip) | | YouTube cookies | `<filesDir>/yt-cookies.txt` (Netscape format) | +| Download queue | `<filesDir>/queue.json` (mirrored on every change; restored on launch) | +| In-progress downloads | `<filesDir>/snaglite-dl/<download-id>/` | | Prefs | `getSharedPreferences("snaglite_yt", MODE_PRIVATE)` + `getSharedPreferences("snaglite_app", MODE_PRIVATE)` | ### Permissions @@ -252,7 +267,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>`** — 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. @@ -261,6 +277,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-<tag>-android-arm64-v8a-release.apk` + - `snaglite-<tag>-android-armeabi-v7a-release.apk` + - `snaglite-<tag>-android-universal-release.apk` + - `snaglite-<tag>-android-x86_64-release.apk` + - `snaglite-<tag>-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. + +> 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. + +--- + ## 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. @@ -270,7 +318,6 @@ The project was previously called **Snag**; the rebrand to **SnagLite** is purel ### Out of scope (Android) -- Playlists / batch input files. +- Batch input files. (YouTube *playlists* are supported — paste a playlist URL and each entry is downloaded in turn, with the card showing "video N of M".) - Subtitles. - Sharing intent (`Send to → SnagLite` from YouTube/etc.). -- Persistence of the download list across process death (in-memory only; queued/paused items are lost if the OS kills the process). diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 184e019..8e5aa3e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,18 +1,24 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) } android { namespace = "com.patron.snaglite" - compileSdk = 34 + compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { applicationId = "com.patron.snaglite" - minSdk = 26 - targetSdk = 34 - versionCode = 1 - versionName = "1.0" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + // versionCode must strictly increase for any install-over-the-top or Play + // upload; versionName is the human-facing string SettingsScreen reads via + // BuildConfig.VERSION_NAME. Keep them moving together. + versionCode = 2 + versionName = "1.1.0" vectorDrawables { useSupportLibrary = true } } @@ -63,10 +69,13 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { jvmTarget = "17" } - buildFeatures { compose = true } - composeOptions { kotlinCompilerExtensionVersion = "1.5.14" } + // buildConfig is off by default from AGP 8; SettingsScreen reads VERSION_NAME + // from it so the About line cannot drift from versionName again. + buildFeatures { + compose = true + buildConfig = true + } splits { abi { @@ -87,27 +96,35 @@ android { } } +kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } +} + 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.core.splashscreen) + 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.androidx.work.runtime.ktx) + 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/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2377ecc..58d27e5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,18 +17,21 @@ <application android:name=".SnagLiteApplication" android:allowBackup="true" + android:backupInForeground="false" + android:dataExtractionRules="@xml/data_extraction_rules" + android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.SnagLite" - tools:targetApi="34"> + tools:targetApi="36"> <activity android:name=".MainActivity" android:exported="true" android:label="@string/app_name" - android:theme="@style/Theme.SnagLite"> + android:theme="@style/Theme.SnagLite.Splash"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> @@ -40,9 +43,16 @@ android:exported="false" android:theme="@style/Theme.SnagLite" /> + <!-- + WorkManager runs the queue's foreground service through its own + SystemForegroundService, which declares no foregroundServiceType of its + own. From API 34 a typed permission requires a typed service, so the type + is merged in here; without it startForeground throws + MissingForegroundServiceTypeException the first time a download starts. + --> <service - android:name=".service.DownloadService" - android:exported="false" - android:foregroundServiceType="dataSync" /> + android:name="androidx.work.impl.foreground.SystemForegroundService" + android:foregroundServiceType="dataSync" + tools:node="merge" /> </application> </manifest> 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..06d41e7 100644 --- a/android/app/src/main/java/com/patron/snaglite/MainActivity.kt +++ b/android/app/src/main/java/com/patron/snaglite/MainActivity.kt @@ -6,16 +6,20 @@ import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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 import com.patron.snaglite.ui.SetupScreen @@ -38,22 +42,30 @@ 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) + // Android 15+ with targetSdk 35+ forces this on regardless; calling it + // explicitly makes API 26–34 behave the same way instead of drawing + // inside opaque system bars. + enableEdgeToEdge() setContent { - SnagLiteTheme { + val app = application as SnagLiteApplication + val themeMode by app.themeMode.collectAsState() + SnagLiteTheme(mode = themeMode) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - 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/SnagLiteApplication.kt b/android/app/src/main/java/com/patron/snaglite/SnagLiteApplication.kt index 33dc44a..e15a316 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,10 @@ 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.ui.ThemeMode +import com.patron.snaglite.ui.themeModeFromPref +import com.patron.snaglite.ui.toPref +import com.patron.snaglite.yt.EngineUpdateChecker import com.patron.snaglite.yt.YouTubePrefs import com.yausername.aria2c.Aria2c import com.yausername.ffmpeg.FFmpeg @@ -16,6 +20,9 @@ import com.yausername.youtubedl_android.YoutubeDL import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -41,11 +48,29 @@ class SnagLiteApplication : Application() { DownloadController(this, applicationScope) } + /** + * Held here rather than in a ViewModel because the theme wraps the whole + * activity, above where any ViewModel is scoped, and the setting has to take + * effect the moment it is changed rather than on next launch. + */ + // Seeded in onCreate, NOT here. A field initializer runs in the Application + // constructor, before attachBaseContext, so touching SharedPreferences at this + // point dereferences a null base context and kills the process at the splash + // screen — before installCrashHandler() has run, so it leaves no crash dump. + private val _themeMode = MutableStateFlow(ThemeMode.System) + val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow() + + fun setThemeMode(mode: ThemeMode) { + SnagLitePrefs.setThemeMode(this, mode.toPref()) + _themeMode.value = mode + } + private val engineMutex = Mutex() override fun onCreate() { installCrashHandler() super.onCreate() + _themeMode.value = themeModeFromPref(SnagLitePrefs.themeMode(this)) createNotificationChannel() // Load the gitignored wrapper-host list from assets (empty if missing). IframeUnwrapper.init(this) @@ -57,9 +82,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..eeabd9c 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 @@ -5,9 +5,11 @@ import android.net.wifi.WifiManager import android.os.PowerManager import android.util.Log import com.patron.snaglite.SnagLiteApplication -import com.patron.snaglite.service.DownloadService +import com.patron.snaglite.service.DownloadQueueWorker import com.patron.snaglite.yt.YouTubePlaylist +import com.patron.snaglite.yt.YouTubeUpdater import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -31,9 +33,22 @@ class DownloadController( private val app: SnagLiteApplication, private val scope: CoroutineScope, ) { - private val _items = MutableStateFlow<List<DownloadItem>>(emptyList()) + // Read synchronously rather than off-thread so the list is already correct the + // first time the UI collects it — an async restore would flash an empty screen + // and then fill, which reads as "my downloads were lost" for exactly as long as + // it takes to notice. The payload is a few KB of JSON. + private val _items = MutableStateFlow(QueueStore.load(app)) val items: StateFlow<List<DownloadItem>> = _items.asStateFlow() + /** + * Restored items are not started here. `pumpQueue` starts a foreground service, + * and this constructor can run while the app is in the background — which + * Android 12+ answers with `ForegroundServiceStartNotAllowedException`. + * [resumeRestored] is called from the UI instead, where a foreground start is + * legal. + */ + @Volatile private var hasResumedRestored = _items.value.none { it.status is DownloadStatus.Queued } + private val jobs = ConcurrentHashMap<String, Job>() private val mutex = Mutex() @@ -134,6 +149,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 } @@ -194,10 +226,18 @@ class DownloadController( processId = id, onProgress = { pct, line -> updateItem(id) { - if (it.status is DownloadStatus.Running) it.copy(status = DownloadStatus.Running(pct, line)) - else it + val cur = it.status + // A negative percent means "status line only, percentage unknown" — + // used by the 403 retry, which resumes from bytes already on disk. + // Overwriting the real percentage there would report a restart that + // is not happening. + if (cur is DownloadStatus.Running) { + it.copy(status = DownloadStatus.Running(if (pct < 0f) cur.percent else pct, line)) + } else { + it + } } - bumpServiceNotification() + refreshLocksThrottled() }, ) applyResult(id, result) @@ -238,7 +278,7 @@ class DownloadController( if (it.status is DownloadStatus.Running) it.copy(status = DownloadStatus.Running(pct, line)) else it } - bumpServiceNotification() + refreshLocksThrottled() }, ) if (result is DownloadResult.Success) { @@ -251,6 +291,7 @@ class DownloadController( Log.w(TAG, "playlist $id item $n failed: ${result.message.take(120)}") } } + if (saved > 0) cleanWorkDir(id) updateItem(id) { if (saved == 0) { it.copy(status = DownloadStatus.Error("No videos from this playlist could be downloaded.")) @@ -271,6 +312,7 @@ class DownloadController( is DownloadResult.Success -> { val published = MediaSink.publishToMovies(app, result.file, result.audioOnly) if (published != null) { + cleanWorkDir(id) updateItem(id) { it.copy( title = published.displayName, @@ -279,17 +321,36 @@ class DownloadController( } } else { updateItem(id) { - it.copy(status = DownloadStatus.Error("Saved to cache but could not publish to the gallery.")) + it.copy( + status = DownloadStatus.Error( + "Downloaded, but couldn't be saved to your gallery", + ), + ) } } } is DownloadResult.Failure -> { if (result.needsYouTubeSignIn) pendingSignInId = id + val needsEngineUpdate = result.needs403Recovery && !result.needsYouTubeSignIn + // A Failure that carries its own detail was written for a person + // already; only raw tool output goes through the classifier. + val classified = result.detail + ?.let { DownloadErrors.Classified(result.message, it) } + ?: DownloadErrors.classify(result.message) + val displayMessage = when { + // These two already have a button attached, so the line only has + // to say what happened — the affordance says what to do. + needsEngineUpdate -> "YouTube blocked this download" + result.needsYouTubeSignIn -> "This video requires signing in" + else -> classified.message + } updateItem(id) { it.copy( status = DownloadStatus.Error( - message = result.message, + message = displayMessage, needsYouTubeSignIn = result.needsYouTubeSignIn, + needsEngineUpdate = needsEngineUpdate, + detail = classified.detail, ), ) } @@ -297,58 +358,137 @@ class DownloadController( } } - private fun workDirFor(id: String): File = File(File(app.cacheDir, "snaglite-dl"), id) + /** + * Deliberately [android.content.Context.getFilesDir] and not `cacheDir`. Downloads + * resume through yt-dlp's `--continue` against partial files kept here, and Android + * is free to reclaim `cacheDir` whenever storage runs low — which it is most likely + * to do while the app is backgrounded, against the largest files, i.e. exactly the + * near-complete download the user just switched away from. + * + * The trade is that nothing reclaims this directory for us, so every terminal path + * has to sweep it: [remove], [clearFinished], and [cleanWorkDir] after a successful + * publish. + */ + private fun workDirFor(id: String): File = File(File(app.filesDir, "snaglite-dl"), id) + + /** + * Drop an item's working directory once its bytes have been published to MediaStore. + * Under `cacheDir` the OS eventually did this for us; under `filesDir` it does not, + * and a kept-but-finished item would otherwise pin a full copy of every download. + */ + private fun cleanWorkDir(id: String) { + runCatching { workDirFor(id).deleteRecursively() } + } private fun currentItem(id: String): DownloadItem? = _items.value.firstOrNull { it.id == id } private fun updateItems(transform: (List<DownloadItem>) -> List<DownloadItem>) { _items.value = transform(_items.value) + persist() } private fun updateItem(id: String, transform: (DownloadItem) -> DownloadItem) { _items.value = _items.value.map { if (it.id == id) transform(it) else it } + persist() } + @Volatile private var lastPersistMs = 0L + /** - * Acquire the wake lock + Wi-Fi lock + foreground service when at least one - * download is active, release when none are. Called after every state - * transition. The Wi-Fi lock prevents the radio from sleeping mid-stream - * when the screen is off; the wake lock keeps the CPU running. + * Mirror the queue to disk. Throttled like [refreshLocksThrottled], because + * progress ticks arrive several times a second and the only thing that changes + * between most of them is a percentage this store deliberately does not keep. + * + * The throttle is skipped whenever the set of items or any of their *states* + * changed rather than just their progress — those are the transitions worth + * losing nothing over. + */ + private fun persist() { + val snapshot = _items.value + val signature = snapshot.joinToString(",") { "${it.id}:${it.status.javaClass.simpleName}" } + val structural = signature != lastPersistSignature + val now = System.currentTimeMillis() + if (!structural && now - lastPersistMs < 2_000L) return + lastPersistMs = now + lastPersistSignature = signature + scope.launch(Dispatchers.IO) { QueueStore.save(app, snapshot) } + } + + @Volatile private var lastPersistSignature: String = "" + + /** + * Start anything restored from disk. Safe to call repeatedly; only the first + * call does anything. Must be invoked from the foreground — see the note on + * [hasResumedRestored]. + */ + fun resumeRestored() { + if (hasResumedRestored) return + hasResumedRestored = true + DownloadQueueWorker.ensureRunning(app) + } + + /** + * Entry point for [DownloadQueueWorker]. The worker holds the foreground service + * but does not run downloads itself, so it calls this once on start to promote + * whatever is waiting; [startItem]'s `finally` keeps the queue moving after that. + */ + internal suspend fun pumpNow() = pumpQueue() + + /** + * Acquire the wake lock + Wi-Fi lock and guarantee a queue worker exists when at + * least one download is active; release the locks when none are. Called after + * every state transition. The Wi-Fi lock prevents the radio from sleeping + * mid-stream when the screen is off; the wake lock keeps the CPU running. + * + * The notification is not built here. [DownloadQueueWorker] owns it and reads the + * same [items] flow, so there is nothing to push — this only ensures one worker is + * running to read it. */ private fun refreshServiceLifecycle() { - val items = _items.value - val active = items.count { it.isActive } + val active = _items.value.count { it.isActive } if (active > 0) { ensureLocks() - val running = items.firstOrNull { it.status is DownloadStatus.Running } ?: items.first { it.isActive } - val pct = (running.status as? DownloadStatus.Running)?.percent - val title = if (active == 1) "SnagLite — downloading" else "SnagLite — $active downloads" - val text = running.title.take(80) + - (pct?.let { " ${"%.0f".format(it)}%" } ?: "") - DownloadService.start(app, title, text) + DownloadQueueWorker.ensureRunning(app) } else { releaseLocks() - DownloadService.stop(app) + // Not cancelled: the worker exits on its own once the queue drains, and + // cancelling here would tear down the foreground service while its final + // notification update is still in flight. } } - @Volatile private var lastNotifMs = 0L - private fun bumpServiceNotification() { + @Volatile private var lastLockRefreshMs = 0L + + /** + * Progress-tick entry point into [refreshServiceLifecycle]. Ticks arrive several + * times a second and none of them change the active/idle answer, so the only + * thing this buys per call is pushing the wake lock's timeout back out — worth + * doing, not worth doing at that rate. + */ + private fun refreshLocksThrottled() { val now = System.currentTimeMillis() - if (now - lastNotifMs < 500L) return - lastNotifMs = now + if (now - lastLockRefreshMs < 500L) return + lastLockRefreshMs = now refreshServiceLifecycle() } @Synchronized private fun ensureLocks() { - if (wakeLock?.isHeld != true) { - val pm = app.getSystemService(Context.POWER_SERVICE) as PowerManager - val wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "snaglite:download") - wl.setReferenceCounted(false) - wl.acquire(2L * 60L * 60L * 1000L) - wakeLock = wl + val pm = app.getSystemService(Context.POWER_SERVICE) as PowerManager + val wl = wakeLock ?: pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "snaglite:download").also { + it.setReferenceCounted(false) + wakeLock = it } + // Re-acquire unconditionally rather than only when !isHeld. The timeout is a + // leak guard, and acquiring again simply restarts it, so an active download + // keeps pushing the deadline out instead of racing it. + // + // The old guard mostly self-healed, because this runs on every state + // transition and a live download ticks constantly. What it could not recover + // from was a download that stalled with no output: nothing called back in, so + // an expired lock stayed expired for as long as the stall lasted — precisely + // when the CPU needs to stay up for the transfer to resume. + wl.acquire(WAKE_LOCK_TIMEOUT_MS) if (wifiLock?.isHeld != true) { val wm = app.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager val fl = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "snaglite:wifi") @@ -369,5 +509,12 @@ class DownloadController( companion object { private const val TAG = "DownloadController" const val MAX_ACTIVE = 3 + + /** + * Leak guard, not a budget. Refreshed on every state transition, so a live + * download never reaches it; it only matters if the app dies without running + * [releaseLocks], where it caps how long a stray lock can hold the CPU up. + */ + private const val WAKE_LOCK_TIMEOUT_MS = 2L * 60L * 60L * 1000L } } diff --git a/android/app/src/main/java/com/patron/snaglite/download/DownloadErrors.kt b/android/app/src/main/java/com/patron/snaglite/download/DownloadErrors.kt new file mode 100644 index 0000000..99b3c17 --- /dev/null +++ b/android/app/src/main/java/com/patron/snaglite/download/DownloadErrors.kt @@ -0,0 +1,104 @@ +package com.patron.snaglite.download + +/** + * Turns raw yt-dlp / resolver output into something worth putting on a card. + * + * The card used to show `response.err` verbatim — a 400-character tail of a + * developer log, `ERROR:` prefix included, cut wherever the limit landed. That is + * the right text for a bug report and the wrong text for a user. + * + * The classifier is deliberately conservative. Matching on another project's error + * strings is matching on something that changes between releases, and a confidently + * wrong message is worse than an opaque one — so anything unrecognised falls through + * to a generic line, and the raw text is always preserved as [Classified.detail] + * rather than discarded. + */ +internal object DownloadErrors { + + data class Classified( + val message: String, + /** Raw tool output, shown only behind the card's Details toggle. */ + val detail: String?, + ) + + private const val GENERIC = "Download failed" + + fun classify(raw: String): Classified { + val text = raw.trim() + if (text.isEmpty()) return Classified(GENERIC, null) + val h = text.lowercase() + + val message = when { + // Order matters, and specific beats generic. "Unable to download webpage" + // is yt-dlp's wrapper around *any* fetch failure — it prefixes 404s and + // 403s as readily as real connectivity problems — so every HTTP status + // has to be tested before anything that could match that wrapper. + h.contains("sign in to confirm") || + h.contains("please sign in") || + h.contains("login required") || + h.contains("age-restricted") || + h.contains("members-only") || + h.contains("private video") -> + "This video requires signing in" + + h.contains("not available in your country") || + h.contains("geo restricted") || + h.contains("geo-restricted") || + h.contains("blocked it in your country") -> + "Not available in your region" + + h.contains("video unavailable") || h.contains("has been removed") -> + "This video is no longer available" + + h.contains("http error 404") || h.contains("not found") -> + "That link doesn't work" + + h.contains("http error 403") || h.contains("forbidden") -> + "The site blocked this download" + + h.contains("http error 429") || h.contains("too many requests") -> + "Too many requests — wait a moment and retry" + + h.contains("no space left") || h.contains("enospc") -> + "Not enough space on your device" + + // Genuine connectivity only. Deliberately excludes "unable to download + // webpage": that fired on an HTTP 404 during testing and produced a + // confident "check your network" on a link that was simply missing. + h.contains("temporary failure in name resolution") || + h.contains("name or service not known") || + h.contains("network is unreachable") || + h.contains("connection refused") || + h.contains("connection reset") || + h.contains("timed out") -> + "No connection — check your network and retry" + + h.contains("unsupported url") || h.contains("no video formats found") -> + "This site isn't supported yet" + + h.contains("is not a valid url") || h.contains("control characters") -> + "That doesn't look like a valid link" + + else -> GENERIC + } + + // Keep the raw text only when it is not already what we are showing, so the + // Details toggle never just repeats the headline. + val detail = text.takeIf { it.isNotBlank() && !it.equals(message, ignoreCase = true) } + return Classified(message, cleanDetail(detail)) + } + + /** + * Tidies raw output for the Details pane: drops yt-dlp's `ERROR:` prefix and + * collapses the blank lines left by concatenating stderr and stdout. Deliberately + * does not truncate — this pane is where the full text belongs. + */ + private fun cleanDetail(raw: String?): String? { + if (raw.isNullOrBlank()) return null + return raw.lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .joinToString("\n") { it.removePrefix("ERROR:").trim() } + .ifBlank { null } + } +} 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..739e288 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,16 @@ 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, + /** + * Raw tool output behind the card's Details toggle. [message] is the plain + * one-liner; this is what someone would paste into a bug report. + */ + val detail: String? = null, + ) : 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..a1e7878 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,14 @@ sealed interface DownloadResult { data class Failure( val message: String, val needsYouTubeSignIn: Boolean = false, + val needs403Recovery: Boolean = false, + /** + * Set when [message] is already a curated one-liner rather than raw tool + * output. Its presence tells [DownloadController] to skip classification — + * running the yt-dlp string matcher over our own prose would be a chance to + * mislabel a message we already got right. + */ + val detail: String? = null, ) : DownloadResult } @@ -37,21 +47,49 @@ object Downloader { // Step 1: iframe unwrap for known listing-page hosts. val pipeline = ResolverPipeline() - var workUrl = try { + val isWrapperHost = pipeline.isWrapperHost(url) + val workUrl = try { pipeline.unwrapIfNeeded(url) } catch (t: Throwable) { Log.w(TAG, "iframe unwrap failed: ${t.message}") url } - if (workUrl != url) Log.i(TAG, "unwrapped iframe: $workUrl") + val unwrapped = workUrl != url + if (unwrapped) Log.i(TAG, "unwrapped iframe: $workUrl") + + // A configured wrapper host whose page yielded no iframe is a different + // failure from an unknown site, and used to arrive as yt-dlp's flat + // "Unsupported URL" — which reads as "this site will never work" when the + // truth is usually "that page had no player on it this time". + if (isWrapperHost && !unwrapped) { + return@withContext DownloadResult.Failure( + "Couldn't find a video player on that page", + detail = "The host is configured for iframe unwrapping, but no <iframe> was " + + "found in the page source. The page may need JavaScript, may be an index " + + "rather than a video page, or its markup may have changed.", + ) + } // Step 2: custom resolver pipeline (wrapper hosts yt-dlp doesn't recognize). // Only attempt when not audio-only (resolvers return mp4 streams). // When a resolver matches the host, we commit to it: yt-dlp's bundled extractors // for these embed sites are months stale, so falling through swaps a specific // error for the unhelpful generic "Unsupported URL". + val matched = pipeline.match(workUrl) + + // Resolvers hand back a progressive mp4, so audio-only cannot go through + // them. Saying that plainly beats letting yt-dlp fail on an embed URL it + // was never going to recognise and reporting it as an unsupported site. + if (audioOnly && matched != null) { + return@withContext DownloadResult.Failure( + "Audio-only isn't available for this source", + detail = "This page's video is served by an embedded player that only provides " + + "a combined video file. Download it as Video instead.", + ) + } + if (!audioOnly) { - val resolver = pipeline.match(workUrl) + val resolver = matched if (resolver != null) { Log.i(TAG, "resolver: ${resolver.name}") onProgress(0f, "Resolving via ${resolver.name}…") @@ -69,20 +107,38 @@ object Downloader { } catch (t: Throwable) { Log.w(TAG, "direct download failed: ${t.message}", t) DownloadResult.Failure( - "Couldn't download from ${resolver.name}: ${t.message ?: t.javaClass.simpleName}. " + - "If the site changed, open an issue with this URL.", + "The video link was found but wouldn't download", + detail = "Resolver ${resolver.name} produced a media URL, but fetching " + + "it failed: ${t.message ?: t.javaClass.simpleName}. The link may " + + "have expired before the download started.", ) } } // resolve() returned null — surface its lastError instead of falling through. - val cause = resolver.lastError ?: "unknown reason" return@withContext DownloadResult.Failure( - "Couldn't extract video from ${resolver.name}: $cause. " + - "Try Settings → Update yt-dlp now if the site recently changed.", + "This page's video player isn't supported yet", + detail = "Resolver ${resolver.name} matched the embed host but could not " + + "extract a media URL: ${resolver.lastError ?: "no reason reported"}. " + + "The player's page layout has probably changed.", ) } } + // Unwrapped to an embed URL that no resolver claims. yt-dlp will almost + // certainly reject it, and "this site isn't supported" would point the blame + // at the listing page rather than at the player it embeds — which is the part + // that would actually need support added. + if (unwrapped && matched == null) { + val embedHost = runCatching { Uri.parse(workUrl).host }.getOrNull() + return@withContext DownloadResult.Failure( + "This page's video player isn't supported yet", + detail = "The page was unwrapped to an embedded player" + + (embedHost?.let { " on $it" } ?: "") + + ", but no resolver recognises that host. Supporting it means adding a " + + "resolver for that player.", + ) + } + ytDlpFlow(context, workUrl, workDir, processId, audioOnly, onProgress) } @@ -94,7 +150,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 +158,39 @@ 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. Sign-in state is + // orthogonal — we retry even when signed in. + // + // The retry deliberately keeps aria2c. An earlier version passed skipAria2c=true + // here on the theory that aria2c amplifies URL drift, but that swap is what made a + // near-complete download start over: aria2c's partial state is the target file plus + // a sibling .aria2 control file, while yt-dlp's native downloader resumes from a + // .part file. Switching downloaders mid-item leaves --continue nothing it + // recognises, so every byte already fetched is thrown away. A 403 is most likely + // late in a long transfer, because YouTube's CDN URLs are signed and time-limited — + // which is exactly when discarding progress hurts most. 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") + // Deliberately not onProgress(0f, …): the retry resumes from the bytes + // already on disk, so zeroing the bar would report a restart that is not + // happening. Keep whatever percentage the item already reached. + onProgress(-1f, "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 = false, onProgress) return finalize(retry, workDir, audioOnly) } return finalize(first, workDir, audioOnly) @@ -143,6 +218,7 @@ object Downloader { audioOnly: Boolean, forceGeneric: Boolean, useFallbackClients: Boolean, + skipAria2c: Boolean, onProgress: (Float, String) -> Unit, ): DownloadResult { val req = YoutubeDLRequest(url).apply { @@ -153,8 +229,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 +260,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/download/QueueStore.kt b/android/app/src/main/java/com/patron/snaglite/download/QueueStore.kt new file mode 100644 index 0000000..ab51010 --- /dev/null +++ b/android/app/src/main/java/com/patron/snaglite/download/QueueStore.kt @@ -0,0 +1,184 @@ +package com.patron.snaglite.download + +import android.content.Context +import android.net.Uri +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** + * Disk mirror of [DownloadController]'s queue. + * + * The queue used to live only in a `MutableStateFlow`, so process death took the + * whole list with it — a force-stop, a low-memory kill, or an aggressive vendor + * battery manager and every pending download was gone with no trace on relaunch. + * This writes the list to `filesDir/queue.json` after each change and reads it + * back on cold start. + * + * Uses `org.json` rather than kotlinx.serialization on purpose: the payload is a + * flat list of small objects, and the alternative costs a dependency plus a + * compiler plugin on a toolchain that CLAUDE.md documents as sensitive to exactly + * that kind of change. + * + * **Progress is deliberately not persisted.** A restored item has no running + * process behind it, so a percentage would be a claim about work nobody is doing. + * Restored in-flight items come back as [DownloadStatus.Queued] and resume through + * yt-dlp's `--continue` against the partial files in the work directory — which is + * why those had to move out of `cacheDir` first. + * + * **[DownloadStatus.Paused] is the exception and comes back paused.** Every other + * non-terminal state describes a process, and the process is gone; Paused describes + * a decision the user made, and that outlives the process. + */ +internal object QueueStore { + + private const val TAG = "QueueStore" + private const val FILE_NAME = "queue.json" + + private const val K_ITEMS = "items" + private const val K_ID = "id" + private const val K_URL = "url" + private const val K_AUDIO_ONLY = "audioOnly" + private const val K_TITLE = "title" + private const val K_CREATED_AT = "createdAt" + private const val K_PLAYLIST_INDEX = "playlistIndex" + private const val K_PLAYLIST_TOTAL = "playlistTotal" + private const val K_THUMBNAIL = "thumbnailUrl" + private const val K_DURATION = "durationSec" + private const val K_UPLOADER = "uploader" + private const val K_FILESIZE = "filesizeBytes" + + private const val K_STATUS = "status" + private const val K_STATUS_URI = "uri" + private const val K_STATUS_NAME = "displayName" + private const val K_STATUS_MESSAGE = "message" + private const val K_STATUS_SIGN_IN = "needsYouTubeSignIn" + private const val K_STATUS_ENGINE = "needsEngineUpdate" + private const val K_STATUS_DETAIL = "detail" + + private const val STATUS_QUEUED = "queued" + private const val STATUS_PAUSED = "paused" + private const val STATUS_DONE = "done" + private const val STATUS_ERROR = "error" + + private fun fileFor(ctx: Context) = File(ctx.filesDir, FILE_NAME) + + /** + * Best-effort write. A failure here loses durability for this tick but must + * never take the download with it, so everything is wrapped. + */ + fun save(ctx: Context, items: List<DownloadItem>) { + runCatching { + val arr = JSONArray() + items.forEach { arr.put(toJson(it)) } + val root = JSONObject().put(K_ITEMS, arr) + // Write to a temp file and rename, so a kill mid-write cannot leave a + // truncated queue.json that fails to parse on the next launch. + val target = fileFor(ctx) + val tmp = File(ctx.filesDir, "$FILE_NAME.tmp") + tmp.writeText(root.toString()) + if (!tmp.renameTo(target)) { + target.writeText(root.toString()) + tmp.delete() + } + }.onFailure { Log.w(TAG, "queue save failed: ${it.message}") } + } + + /** Returns an empty list when the file is absent, unreadable, or corrupt. */ + fun load(ctx: Context): List<DownloadItem> = runCatching { + val f = fileFor(ctx) + if (!f.exists()) return emptyList() + val root = JSONObject(f.readText()) + val arr = root.optJSONArray(K_ITEMS) ?: return emptyList() + (0 until arr.length()).mapNotNull { i -> + runCatching { fromJson(arr.getJSONObject(i)) }.getOrNull() + } + }.onFailure { Log.w(TAG, "queue load failed: ${it.message}") }.getOrDefault(emptyList()) + + fun clear(ctx: Context) { + runCatching { fileFor(ctx).delete() } + } + + private fun toJson(item: DownloadItem): JSONObject = JSONObject().apply { + put(K_ID, item.id) + put(K_URL, item.url) + put(K_AUDIO_ONLY, item.audioOnly) + put(K_TITLE, item.title) + put(K_CREATED_AT, item.createdAt) + item.playlistIndex?.let { put(K_PLAYLIST_INDEX, it) } + item.playlistTotal?.let { put(K_PLAYLIST_TOTAL, it) } + item.thumbnailUrl?.let { put(K_THUMBNAIL, it) } + item.durationSec?.let { put(K_DURATION, it) } + item.uploader?.let { put(K_UPLOADER, it) } + item.filesizeBytes?.let { put(K_FILESIZE, it) } + put(K_STATUS, statusToJson(item.status)) + } + + private fun statusToJson(status: DownloadStatus): JSONObject = JSONObject().apply { + when (status) { + is DownloadStatus.Done -> { + put(K_STATUS, STATUS_DONE) + put(K_STATUS_URI, status.publicUri.toString()) + put(K_STATUS_NAME, status.displayName) + } + is DownloadStatus.Error -> { + put(K_STATUS, STATUS_ERROR) + put(K_STATUS_MESSAGE, status.message) + put(K_STATUS_SIGN_IN, status.needsYouTubeSignIn) + put(K_STATUS_ENGINE, status.needsEngineUpdate) + // Without this the Details toggle silently disappears on the next + // launch — the one moment someone is most likely to go looking for + // it, having just been interrupted. + status.detail?.let { put(K_STATUS_DETAIL, it) } + } + // Paused survives. It is the one non-terminal state that records a + // decision rather than a process: the user asked for this download to + // stop, and restarting it behind their back on the next launch is the + // opposite of what they asked for — expensively so on metered data, + // which is usually why someone pauses a download in the first place. + // The percentage is still dropped, per the note on this object. + is DownloadStatus.Paused -> put(K_STATUS, STATUS_PAUSED) + // Queued, Preparing and Running all collapse to Queued: their process is + // gone once this file is read back, so any other state would describe + // work that is not happening. + else -> put(K_STATUS, STATUS_QUEUED) + } + } + + private fun fromJson(o: JSONObject): DownloadItem = DownloadItem( + id = o.getString(K_ID), + url = o.getString(K_URL), + audioOnly = o.optBoolean(K_AUDIO_ONLY, false), + title = o.optString(K_TITLE, o.getString(K_URL)), + status = statusFromJson(o.optJSONObject(K_STATUS)), + playlistIndex = if (o.has(K_PLAYLIST_INDEX)) o.getInt(K_PLAYLIST_INDEX) else null, + playlistTotal = if (o.has(K_PLAYLIST_TOTAL)) o.getInt(K_PLAYLIST_TOTAL) else null, + createdAt = o.optLong(K_CREATED_AT, System.currentTimeMillis()), + thumbnailUrl = if (o.has(K_THUMBNAIL)) o.getString(K_THUMBNAIL) else null, + durationSec = if (o.has(K_DURATION)) o.getInt(K_DURATION) else null, + uploader = if (o.has(K_UPLOADER)) o.getString(K_UPLOADER) else null, + filesizeBytes = if (o.has(K_FILESIZE)) o.getLong(K_FILESIZE) else null, + ) + + private fun statusFromJson(o: JSONObject?): DownloadStatus { + if (o == null) return DownloadStatus.Queued + return when (o.optString(K_STATUS, STATUS_QUEUED)) { + STATUS_DONE -> DownloadStatus.Done( + publicUri = Uri.parse(o.optString(K_STATUS_URI)), + displayName = o.optString(K_STATUS_NAME), + ) + // Percent 0f, not a remembered one: the partial file is on disk and + // `--continue` will pick it up, but nothing has measured it since the + // process died, so any number here would be a guess dressed as progress. + STATUS_PAUSED -> DownloadStatus.Paused(0f, "Paused") + STATUS_ERROR -> DownloadStatus.Error( + message = o.optString(K_STATUS_MESSAGE), + needsYouTubeSignIn = o.optBoolean(K_STATUS_SIGN_IN, false), + needsEngineUpdate = o.optBoolean(K_STATUS_ENGINE, false), + detail = if (o.has(K_STATUS_DETAIL)) o.getString(K_STATUS_DETAIL) else null, + ) + else -> DownloadStatus.Queued + } + } +} diff --git a/android/app/src/main/java/com/patron/snaglite/download/SnagLitePrefs.kt b/android/app/src/main/java/com/patron/snaglite/download/SnagLitePrefs.kt index e93777a..e34273f 100644 --- a/android/app/src/main/java/com/patron/snaglite/download/SnagLitePrefs.kt +++ b/android/app/src/main/java/com/patron/snaglite/download/SnagLitePrefs.kt @@ -9,6 +9,12 @@ object SnagLitePrefs { private const val K_DELETE_FILE_ON_REMOVE = "delete_file_on_remove" private const val K_BATT_OPT_DONT_ASK = "batt_opt_dont_ask" private const val K_MEDIA_MIGRATED = "media_migrated_v1" + private const val K_THEME_MODE = "theme_mode" + + /** Stored as a stable string, not an ordinal — reordering the enum must not resettle a user's choice. */ + const val THEME_SYSTEM = "system" + const val THEME_LIGHT = "light" + const val THEME_DARK = "dark" private fun sp(ctx: Context): SharedPreferences = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -27,6 +33,13 @@ object SnagLitePrefs { sp(ctx).edit().putBoolean(K_BATT_OPT_DONT_ASK, v).apply() } + fun themeMode(ctx: Context): String = + sp(ctx).getString(K_THEME_MODE, THEME_SYSTEM) ?: THEME_SYSTEM + + fun setThemeMode(ctx: Context, v: String) { + sp(ctx).edit().putString(K_THEME_MODE, v).apply() + } + fun mediaMigrated(ctx: Context): Boolean = sp(ctx).getBoolean(K_MEDIA_MIGRATED, false) diff --git a/android/app/src/main/java/com/patron/snaglite/download/resolvers/ResolverPipeline.kt b/android/app/src/main/java/com/patron/snaglite/download/resolvers/ResolverPipeline.kt index 28ebc92..5260c05 100644 --- a/android/app/src/main/java/com/patron/snaglite/download/resolvers/ResolverPipeline.kt +++ b/android/app/src/main/java/com/patron/snaglite/download/resolvers/ResolverPipeline.kt @@ -13,6 +13,17 @@ class ResolverPipeline { ResolverC(), ) + /** + * True when the host is in the runtime wrapper list, i.e. we expected to find an + * iframe on it. Lets the caller tell "configured wrapper host that yielded + * nothing" apart from "site we know nothing about" — two failures that otherwise + * arrive looking identical. + */ + fun isWrapperHost(url: String): Boolean { + val host = Uri.parse(url).host?.lowercase() ?: return false + return IframeUnwrapper.shouldUnwrap(host) + } + fun match(url: String): IResolver? { val host = Uri.parse(url).host?.lowercase() ?: return null return resolvers.firstOrNull { it.matches(host) } diff --git a/android/app/src/main/java/com/patron/snaglite/service/DownloadQueueWorker.kt b/android/app/src/main/java/com/patron/snaglite/service/DownloadQueueWorker.kt new file mode 100644 index 0000000..08ac687 --- /dev/null +++ b/android/app/src/main/java/com/patron/snaglite/service/DownloadQueueWorker.kt @@ -0,0 +1,196 @@ +package com.patron.snaglite.service + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.patron.snaglite.MainActivity +import com.patron.snaglite.R +import com.patron.snaglite.SnagLiteApplication +import com.patron.snaglite.download.DownloadController +import com.patron.snaglite.download.DownloadItem +import com.patron.snaglite.download.DownloadStatus +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach + +/** + * Owns the foreground service for the whole queue, and stays alive until the queue + * drains. Replaces `DownloadService`. + * + * **One worker for the queue, not one per download.** A worker per item would have + * each fighting over the same notification id, would need the `MAX_ACTIVE` cap + * reimplemented on top of a WorkManager that has no per-tag parallelism control, and + * would gain nothing: the downloads already run as coroutines on the application + * scope and are already capped. So this worker does not run downloads itself — it + * holds the foreground service, publishes the aggregate notification, and waits. + * + * What that buys over the old service: WorkManager persists its own work across + * process death *and reboots* and reschedules it, and an expedited worker may start a + * foreground service from the background — which is what + * `ForegroundServiceStartNotAllowedException` blocked when `pumpQueue` tried it + * directly. See [ensureRunning] for what "expedited" is and is not worth. + * + * Because it runs in the same process it talks to [DownloadController] directly, so + * progress still flows through the existing `StateFlow` and no UI code changes. + */ +class DownloadQueueWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + + private val app get() = applicationContext as SnagLiteApplication + + override suspend fun getForegroundInfo(): ForegroundInfo = + foregroundInfo(app.downloads.items.value) + + override suspend fun doWork(): Result { + val controller = app.downloads + + // A failed foreground start is not a log line. Without it there is no + // service and no notification, yet the downloads keep running on the + // application scope — an unprotected process the OS may reclaim + // mid-transfer, with nothing on screen to say so. Retrying costs a backoff + // delay and by the next attempt the app may be foreground, or the + // Android 15 dataSync quota refilled. + val started = runCatching { setForeground(getForegroundInfo()) } + .onFailure { Log.w(TAG, "setForeground failed, retrying: ${it.message}") } + .isSuccess + + // Kick anything restored from disk or enqueued while we were starting. + // This runs even when the foreground start failed, and deliberately so: the + // queue draining unprotected is worse than it draining protected, but it is + // far better than it not draining at all. Gating the pump on the service + // would mean a device that persistently refuses a background foreground-start + // never resumes its queue — a regression against the old service, which + // pumped regardless and merely logged. + controller.pumpNow() + + // Retry only after the pump, so the work still makes progress in the + // meantime. WorkManager re-runs this worker with backoff and tries the + // foreground start again; the downloads it just started are unaffected, + // since those coroutines live on the application scope and are not ours to + // stop. + if (!started) return Result.retry() + + // Suspend until nothing is active or waiting, refreshing the notification as + // the queue changes. first{} both waits for the drain condition and lets + // onEach see every intermediate state. + runCatching { + controller.items + .onEach { items -> maybeUpdateNotification(items) } + .first { items -> items.none { it.isActive || it.status is DownloadStatus.Queued } } + }.onFailure { Log.w(TAG, "queue wait ended: ${it.message}") } + + return Result.success() + } + + private var lastNotifMs = 0L + + private suspend fun maybeUpdateNotification(items: List<DownloadItem>) { + val now = System.currentTimeMillis() + // Same 500 ms guard the old service used. Progress arrives several times a + // second and each update redraws the shade row. + if (now - lastNotifMs < 500L) return + lastNotifMs = now + runCatching { setForeground(foregroundInfo(items)) } + } + + private fun foregroundInfo(items: List<DownloadItem>): ForegroundInfo { + val active = items.filter { it.isActive } + val queued = items.count { it.status is DownloadStatus.Queued } + + val percents = active.map { (it.status as? DownloadStatus.Running)?.percent ?: 0f } + val mean = if (percents.isEmpty()) 0f else percents.average().toFloat() + val indeterminate = mean <= 0f + val pct = mean.toInt().coerceIn(0, 100) + + val title = when { + active.isEmpty() -> app.getString(R.string.notif_title_starting) + queued > 0 -> app.resources.getQuantityString( + R.plurals.notif_title_active_queued, active.size, active.size, queued, + ) + else -> app.resources.getQuantityString( + R.plurals.notif_title_active, active.size, active.size, + ) + } + val shown = active.firstOrNull { it.status is DownloadStatus.Running } ?: active.firstOrNull() + val text = if (indeterminate || shown == null) { + app.getString(R.string.notif_text_preparing) + } else { + app.getString(R.string.notif_text_progress, shown.title.take(80), pct) + } + + val notif = buildNotification(title, text, pct, indeterminate) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ForegroundInfo(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(NOTIF_ID, notif) + } + } + + private fun buildNotification( + title: String, + text: String, + percent: Int, + indeterminate: Boolean, + ): Notification { + val tap = PendingIntent.getActivity( + app, 0, + Intent(app, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + return NotificationCompat.Builder(app, SnagLiteApplication.CHANNEL_ID) + .setSmallIcon(R.drawable.ic_download) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(tap) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setProgress(100, percent, indeterminate) + .build() + } + + companion object { + private const val TAG = "DownloadQueueWorker" + private const val NOTIF_ID = 1001 + private const val WORK_NAME = "snaglite-download-queue" + + /** + * Idempotent. [ExistingWorkPolicy.KEEP] means a worker already draining the + * queue keeps running rather than being replaced mid-download — every + * enqueue path calls this, so REPLACE would cancel the foreground service + * every time a second item was added. + * + * Expedited is a *request*, not a guarantee: the app has a finite expedited + * quota per 24 hours, and once it is spent the scheduler treats the request + * as ordinary work. That is why the fallback is + * [OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST] and not + * `DROP_WORK_REQUEST` — a queue drain that starts late is an inconvenience, + * one that never starts is a queue the user has to notice and restart by + * hand. Expedited work needs [getForegroundInfo] on API < 31, which this + * worker already overrides. + */ + fun ensureRunning(ctx: Context) { + val req = OneTimeWorkRequestBuilder<DownloadQueueWorker>() + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + WorkManager.getInstance(ctx) + .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.KEEP, req) + } + + fun cancel(ctx: Context) { + WorkManager.getInstance(ctx).cancelUniqueWork(WORK_NAME) + } + } +} diff --git a/android/app/src/main/java/com/patron/snaglite/service/DownloadService.kt b/android/app/src/main/java/com/patron/snaglite/service/DownloadService.kt deleted file mode 100644 index b435920..0000000 --- a/android/app/src/main/java/com/patron/snaglite/service/DownloadService.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.patron.snaglite.service - -import android.app.Notification -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo -import android.os.Build -import android.os.IBinder -import androidx.core.app.NotificationCompat -import com.patron.snaglite.MainActivity -import com.patron.snaglite.R -import com.patron.snaglite.SnagLiteApplication - -/** - * Foreground service holding the download notification so the OS won't kill us - * mid-download when the activity goes to the background. The actual download - * work runs in the ViewModel's coroutine — this service is a lifecycle holder. - */ -class DownloadService : Service() { - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val title = intent?.getStringExtra(EXTRA_TITLE) ?: "Downloading" - val text = intent?.getStringExtra(EXTRA_TEXT) ?: "Working…" - val notif = buildNotification(title, text) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) - } else { - startForeground(NOTIF_ID, notif) - } - return START_STICKY - } - - private fun buildNotification(title: String, text: String): Notification { - val tap = PendingIntent.getActivity( - this, 0, - Intent(this, MainActivity::class.java), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - return NotificationCompat.Builder(this, SnagLiteApplication.CHANNEL_ID) - .setSmallIcon(R.drawable.ic_download) - .setContentTitle(title) - .setContentText(text) - .setContentIntent(tap) - .setOngoing(true) - .setOnlyAlertOnce(true) - .build() - } - - companion object { - const val EXTRA_TITLE = "title" - const val EXTRA_TEXT = "text" - private const val NOTIF_ID = 1001 - - fun start(ctx: Context, title: String, text: String) { - val i = Intent(ctx, DownloadService::class.java).apply { - putExtra(EXTRA_TITLE, title) - putExtra(EXTRA_TEXT, text) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i) - else ctx.startService(i) - } - - fun stop(ctx: Context) { - ctx.stopService(Intent(ctx, DownloadService::class.java)) - } - } -} 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..425e012 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 @@ -2,6 +2,7 @@ package com.patron.snaglite.ui import android.content.Intent import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,6 +22,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 +66,7 @@ fun DownloadItemCard( onResume: () -> Unit, onRequestRemove: () -> Unit, onSignIn: () -> Unit, + onUpdateEngine: () -> Unit, ) { val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { value -> @@ -77,7 +80,7 @@ fun DownloadItemCard( SwipeToDismissBox( state = dismissState, backgroundContent = { SwipeBackground() }, - content = { CardBody(item, onPause, onResume, onSignIn) }, + content = { CardBody(item, onPause, onResume, onSignIn, onUpdateEngine) }, ) } @@ -86,6 +89,10 @@ private fun SwipeBackground() { Box( modifier = Modifier .fillMaxSize() + // Clip to the card's own shape. Without this the reveal is a plain + // rectangle sitting behind a rounded card, so its error colour shows + // as four red corners on every row even when nothing is being swiped. + .clip(MaterialTheme.shapes.large) .background(MaterialTheme.colorScheme.errorContainer) .padding(horizontal = 20.dp), contentAlignment = Alignment.CenterEnd, @@ -104,6 +111,7 @@ private fun CardBody( onPause: () -> Unit, onResume: () -> Unit, onSignIn: () -> Unit, + onUpdateEngine: () -> Unit, ) { val ctx = LocalContext.current Card( @@ -142,7 +150,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 { @@ -311,7 +319,34 @@ private fun StatusBlock(status: DownloadStatus) { StatusText("Paused", MaterialTheme.colorScheme.onSurfaceVariant) } is DownloadStatus.Done -> StatusText("Saved to gallery", MaterialTheme.colorScheme.primary) - is DownloadStatus.Error -> StatusText(status.message, MaterialTheme.colorScheme.error) + is DownloadStatus.Error -> ErrorText(status) + } +} + +/** + * Plain one-liner, with the raw tool output tucked behind a toggle. Collapsed by + * default: the headline is what a user needs, the rest is what a bug report needs. + */ +@Composable +private fun ErrorText(status: DownloadStatus.Error) { + var showDetail by remember { mutableStateOf(false) } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + StatusText(status.message, MaterialTheme.colorScheme.error) + if (!status.detail.isNullOrBlank()) { + Text( + if (showDetail) "Hide details" else "Details", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { showDetail = !showDetail }, + ) + if (showDetail) { + Text( + status.detail, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } @@ -332,6 +367,7 @@ private fun TrailingAction( onPause: () -> Unit, onResume: () -> Unit, onSignIn: () -> Unit, + onUpdateEngine: () -> Unit, onOpen: (android.net.Uri) -> Unit, ) { when (val s = item.status) { @@ -363,13 +399,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..33058da 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 @@ -24,6 +25,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 @@ -31,16 +33,24 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope 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.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.patron.snaglite.R +import com.patron.snaglite.download.DownloadController import com.patron.snaglite.download.DownloadItem +import com.patron.snaglite.download.DownloadStatus import com.patron.snaglite.download.SnagLitePrefs import com.patron.snaglite.webview.YouTubeSignInActivity +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -53,7 +63,8 @@ fun MainScreen( var url by remember { mutableStateOf("") } var showSettings by remember { mutableStateOf(false) } var pendingRemoval by remember { mutableStateOf<DownloadItem?>(null) } - val clipboard = LocalClipboardManager.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() val ctx = LocalContext.current var showBattDialog by remember { mutableStateOf(shouldPromptBattOpt(ctx)) } @@ -65,6 +76,8 @@ fun MainScreen( } } + BackHandler(enabled = showSettings) { showSettings = false } + if (showSettings) { SettingsScreen( onBack = { showSettings = false }, @@ -77,98 +90,150 @@ fun MainScreen( } val hasFinished = items.any { it.isTerminal } + val hasQueued = items.any { it.status is DownloadStatus.Queued } + 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 = { - clipboard.getText()?.text?.let { url = it.trim() } - }) { - 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.Settings, contentDescription = stringResource(R.string.cd_settings)) } - } - 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( + }, + ) { inner -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(inner) + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text(stringResource(R.string.main_url_label)) }, + singleLine = true, 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") + 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 = stringResource(R.string.cd_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( + stringResource( + if (choice == FormatChoice.Video) { + R.string.main_format_video + } else { + R.string.main_format_audio + }, + ), + ) } } } - LazyColumn( + Button( + onClick = { + vm.enqueue(url) + url = "" + }, + enabled = url.isNotBlank(), 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)) - }, + ) { Text(stringResource(R.string.main_download)) } + + if (items.isEmpty()) { + // Own Column so the heading sits tight against its body rather + // than inheriting the parent's 12.dp control spacing, and so the + // pair centres as one block instead of two separately-centred lines. + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + stringResource(R.string.main_empty_title), + style = MaterialTheme.typography.titleSmall, + textAlign = TextAlign.Center, + ) + Text( + stringResource(R.string.main_empty_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.main_downloads_header), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + if (hasFinished) { + TextButton(onClick = { vm.clearFinished() }) { + Text(stringResource(R.string.main_clear_finished)) + } + } + } + if (hasQueued) { + Text( + pluralStringResource( + R.plurals.main_queue_note, + DownloadController.MAX_ACTIVE, + DownloadController.MAX_ACTIVE, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + 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/MainViewModel.kt b/android/app/src/main/java/com/patron/snaglite/ui/MainViewModel.kt index 2dfe8e9..863a4c0 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 @@ -18,6 +18,14 @@ class MainViewModel(private val app: Application) : AndroidViewModel(app) { private val sapp = app as SnagLiteApplication + init { + // Anything restored from disk is Queued with no process behind it. Starting + // it here rather than in the controller's constructor is deliberate: this + // ViewModel is built by the UI, so the app is in the foreground and the + // foreground-service start that pumpQueue performs is permitted. + sapp.downloads.resumeRestored() + } + val items: StateFlow<List<DownloadItem>> = sapp.downloads.items private val _format = MutableStateFlow(FormatChoice.Video) @@ -39,4 +47,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/SettingsScreen.kt b/android/app/src/main/java/com/patron/snaglite/ui/SettingsScreen.kt index 16c63a6..a7eff04 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,9 +18,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -31,6 +35,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import com.patron.snaglite.BuildConfig +import com.patron.snaglite.SnagLiteApplication import com.patron.snaglite.download.SnagLitePrefs import com.patron.snaglite.yt.YouTubeUpdater import kotlinx.coroutines.launch @@ -41,96 +47,117 @@ fun SettingsScreen( onReRunSetup: () -> Unit, ) { val ctx = LocalContext.current + val sapp = ctx.applicationContext as SnagLiteApplication + val themeMode by sapp.themeMode.collectAsState() 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() + Text("Appearance", style = MaterialTheme.typography.titleMedium) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + val modes = ThemeMode.entries + modes.forEachIndexed { idx, mode -> + SegmentedButton( + selected = themeMode == mode, + onClick = { sapp.setThemeMode(mode) }, + shape = SegmentedButtonDefaults.itemShape(idx, modes.size), + ) { + Text( + when (mode) { + ThemeMode.System -> "System" + ThemeMode.Light -> "Light" + ThemeMode.Dark -> "Dark" + }, + ) + } } - }, - enabled = !updating, - modifier = Modifier.fillMaxWidth(), - ) { Text(if (updating) "Updating…" else "Update download engine") } + } + Text( + "Applies to the app straight away. The brief window-background frame shown " + + "while the app is cold-launching still follows the system setting.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + HorizontalDivider() - OutlinedButton( - onClick = onReRunSetup, - modifier = Modifier.fillMaxWidth(), - ) { Text("Re-run initial setup") } + 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") } - 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) + // Read from BuildConfig, not a literal — the previous hardcoded string had + // already drifted from versionName in build.gradle.kts. + Text("SnagLite — Version ${BuildConfig.VERSION_NAME}", 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..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,25 +32,35 @@ 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() } - 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/SetupViewModel.kt b/android/app/src/main/java/com/patron/snaglite/ui/SetupViewModel.kt index 2a81858..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 @@ -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 @@ -28,6 +31,14 @@ 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. 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 + private val initial = listOf( SetupStep("engines", "Installing download engine"), SetupStep("ytdlp", "Updating download engine"), @@ -39,19 +50,44 @@ class SetupViewModel(private val app: Application) : AndroidViewModel(app) { val state: StateFlow<SetupUiState> = _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 { runStep("engines") { val sapp = app as SnagLiteApplication 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/SnagLiteScaffold.kt b/android/app/src/main/java/com/patron/snaglite/ui/SnagLiteScaffold.kt new file mode 100644 index 0000000..466fb01 --- /dev/null +++ b/android/app/src/main/java/com/patron/snaglite/ui/SnagLiteScaffold.kt @@ -0,0 +1,62 @@ +package com.patron.snaglite.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawing +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, + contentWindowInsets = WindowInsets.safeDrawing, + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = navigationIcon, + actions = actions, + scrollBehavior = scrollBehavior, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + content = content, + ) +} diff --git a/android/app/src/main/java/com/patron/snaglite/ui/Theme.kt b/android/app/src/main/java/com/patron/snaglite/ui/Theme.kt index fa4194a..dd0733c 100644 --- a/android/app/src/main/java/com/patron/snaglite/ui/Theme.kt +++ b/android/app/src/main/java/com/patron/snaglite/ui/Theme.kt @@ -10,6 +10,7 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import com.patron.snaglite.download.SnagLitePrefs private val LightColors = lightColorScheme( primary = Color(0xFF2962FF), @@ -23,9 +24,36 @@ private val DarkColors = darkColorScheme( secondary = Color(0xFF1DE9B6), ) +enum class ThemeMode { System, Light, Dark } + +fun themeModeFromPref(value: String): ThemeMode = when (value) { + SnagLitePrefs.THEME_LIGHT -> ThemeMode.Light + SnagLitePrefs.THEME_DARK -> ThemeMode.Dark + else -> ThemeMode.System +} + +fun ThemeMode.toPref(): String = when (this) { + ThemeMode.Light -> SnagLitePrefs.THEME_LIGHT + ThemeMode.Dark -> SnagLitePrefs.THEME_DARK + ThemeMode.System -> SnagLitePrefs.THEME_SYSTEM +} + @Composable fun SnagLiteTheme( - darkTheme: Boolean = isSystemInDarkTheme(), + mode: ThemeMode = ThemeMode.System, + content: @Composable () -> Unit, +) { + val darkTheme = when (mode) { + ThemeMode.Light -> false + ThemeMode.Dark -> true + ThemeMode.System -> isSystemInDarkTheme() + } + SnagLiteTheme(darkTheme = darkTheme, content = content) +} + +@Composable +private fun SnagLiteTheme( + darkTheme: Boolean, content: @Composable () -> Unit, ) { val colors = when { 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<EngineUpdateChecker.Result?>(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/webview/YouTubeSignInActivity.kt b/android/app/src/main/java/com/patron/snaglite/webview/YouTubeSignInActivity.kt index 4078978..3f25e64 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,9 @@ import android.widget.Button import android.widget.LinearLayout import android.widget.TextView import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import com.patron.snaglite.yt.CookieStore import com.patron.snaglite.yt.YouTubePrefs @@ -22,6 +25,13 @@ class YouTubeSignInActivity : ComponentActivity() { @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Same call MainActivity makes, and for the same reason: it picks the + // system-bar icon appearance from the current night mode. Without it + // this activity keeps the platform default — light icons — which are + // near-invisible against its light window background in day mode. The + // status bar is affected in both navigation modes, the navigation bar + // only in three-button, where it is drawn rather than a gesture pill. + enableEdgeToEdge() val container = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL @@ -73,6 +83,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) 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) diff --git a/android/app/src/main/res/values-night-v29/themes.xml b/android/app/src/main/res/values-night-v29/themes.xml new file mode 100644 index 0000000..c291ecb --- /dev/null +++ b/android/app/src/main/res/values-night-v29/themes.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- + Exists only to win back API 29+ in night mode. Android resolves the night + qualifier ahead of the version qualifier, so values-night/themes.xml would + otherwise outrank values-v29/themes.xml on API 29+ and silently replace the + DeviceDefault.DayNight parent with a fixed dark one. This folder carries + both qualifiers, so it is more specific than either. + + Keep this style byte-identical to values-v29/themes.xml. It is a duplicate + on purpose: the two cannot be merged without reintroducing the precedence + bug described above. + --> + <style name="Theme.SnagLite" parent="android:Theme.DeviceDefault.DayNight"> + <item name="android:windowActionBar">false</item> + <item name="android:windowNoTitle">true</item> + <item name="android:windowBackground">@color/snaglite_window_background</item> + <item name="android:navigationBarColor">@android:color/transparent</item> + <item name="android:statusBarColor">@android:color/transparent</item> + </style> + +</resources> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="snaglite_window_background">#1C1B1F</color> + <color name="snaglite_splash_background">#1C1B1F</color> +</resources> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- + Night override for API 26-28 only — but Android's qualifier precedence + puts night mode *above* platform version, so this file would also beat + values-v29/themes.xml on API 29+ in night mode. That is why + values-night-v29/themes.xml exists: it re-states the v29 DayNight style + at a qualifier specific enough to win back API 29+. Do not delete one of + these two files without deleting the other. + + Before this file existed, the Light parent in values/themes.xml applied in + night mode too on API 26-28, rendering ?android:textColorPrimary near-black + against the #1C1B1F night window background. Only YouTubeSignInActivity is + affected — it is the app's one View hierarchy; every Compose screen takes + its colours from MaterialTheme. + --> + <style name="Theme.SnagLite" parent="android:Theme.Material.NoActionBar"> + <item name="android:windowBackground">@color/snaglite_window_background</item> + <item name="android:navigationBarColor">@android:color/transparent</item> + <item name="android:statusBarColor">@android:color/transparent</item> + </style> + +</resources> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <!-- + API 29+ override: DayNight parent so the platform theme's own colours + follow the system dark-mode setting, not just the window background. + Keep the three items in sync with values/themes.xml. + + Deviation from the brief: the brief specifies parent + "android:Theme.DeviceDefault.DayNight.NoActionBar", but that style does + not exist in the public Android framework at any compileSdk level + (confirmed against compileSdk 36's platform styles: only + Theme.DeviceDefault.DayNight exists, with no NoActionBar sibling — + android:Theme.DeviceDefault.NoActionBar exists but is not DayNight- + aware). Using the non-existent style fails aapt2 linking. Instead this + parents on the real DayNight-switching style, android:Theme. + DeviceDefault.DayNight (values/values-night swap under the hood, + confirmed in the platform's themes_device_defaults.xml), and reproduces + NoActionBar the same way the framework's own Theme.Material.NoActionBar + does it: windowActionBar=false + windowNoTitle=true. + --> + <style name="Theme.SnagLite" parent="android:Theme.DeviceDefault.DayNight"> + <item name="android:windowActionBar">false</item> + <item name="android:windowNoTitle">true</item> + <item name="android:windowBackground">@color/snaglite_window_background</item> + <item name="android:navigationBarColor">@android:color/transparent</item> + <item name="android:statusBarColor">@android:color/transparent</item> + </style> + +</resources> 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 @@ <?xml version="1.0" encoding="utf-8"?> <resources> <color name="ic_launcher_background">#FF1565C0</color> + <color name="snaglite_window_background">#FFFBFE</color> + <color name="snaglite_splash_background">#FFFBFE</color> </resources> diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5317367..56b4bca 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,53 @@ <?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">SnagLite</string> + + <!-- Home screen --> + <!-- + Deliberately format-neutral. The Video/Audio toggle picks the output format, + not the input: the same page link serves both, so a label that followed the + toggle would imply an "Audio URL" the user has to find and does not have. + --> + <string name="main_url_label">URL</string> + <string name="main_format_video">Video</string> + <string name="main_format_audio">Audio</string> + <string name="main_download">Download</string> + <string name="main_downloads_header">Downloads</string> + <string name="main_clear_finished">Clear finished</string> + + <string name="main_empty_title">No downloads yet</string> + <string name="main_empty_body">Paste a link and tap Download.</string> + + <!-- + %1$d is DownloadController.MAX_ACTIVE. Shown only while something is + actually waiting, so the limit is explained at the moment it bites. + --> + <plurals name="main_queue_note"> + <item quantity="one">%1$d at a time — the rest are waiting.</item> + <item quantity="other">%1$d at a time — the rest are waiting.</item> + </plurals> + + <!-- Foreground-service notification --> + <!-- + %1$d is the number of active downloads. Plural even though English does not + distinguish these two forms in the "one" case, because most locales do. + --> + <plurals name="notif_title_active"> + <item quantity="one">SnagLite — downloading</item> + <item quantity="other">SnagLite — %1$d downloading</item> + </plurals> + <!-- %1$d active, %2$d waiting. Used only while something is actually queued. --> + <plurals name="notif_title_active_queued"> + <item quantity="one">SnagLite — %1$d downloading, %2$d queued</item> + <item quantity="other">SnagLite — %1$d downloading, %2$d queued</item> + </plurals> + <!-- Shown for the moment between the worker taking the foreground and the first item going active. --> + <string name="notif_title_starting">SnagLite — starting</string> + <string name="notif_text_preparing">Preparing…</string> + <!-- %1$s is the item title, %2$d its percentage. --> + <string name="notif_text_progress">%1$s %2$d%%</string> + + <!-- Content descriptions --> + <string name="cd_settings">Settings</string> + <string name="cd_paste">Paste</string> </resources> diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index abe0eff..04114e6 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -1,4 +1,41 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <style name="Theme.SnagLite" parent="android:Theme.Material.Light.NoActionBar" /> + + <!-- + Post-splash theme, API 26-28. See values-v29/themes.xml for the DayNight + parent used on API 29+. The window background must track dark mode or a + 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 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. + --> + <style name="Theme.SnagLite" parent="android:Theme.Material.Light.NoActionBar"> + <item name="android:windowBackground">@color/snaglite_window_background</item> + <item name="android:navigationBarColor">@android:color/transparent</item> + <item name="android:statusBarColor">@android:color/transparent</item> + </style> + + <!-- + Launch theme, handed to the androidx SplashScreen compat layer. Applied via + AndroidManifest; MainActivity calls installSplashScreen() before + super.onCreate(), which swaps the window over to Theme.SnagLite. + --> + <style name="Theme.SnagLite.Splash" parent="Theme.SplashScreen"> + <item name="windowSplashScreenBackground">@color/snaglite_splash_background</item> + <item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item> + <item name="postSplashScreenTheme">@style/Theme.SnagLite</item> + </style> + </resources> diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fc3090e --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Auto Backup exclusions for API 30 and below. + Keep in sync with res/xml/data_extraction_rules.xml (API 31+). + + yt-cookies.txt is a live Netscape-format YouTube session cookie jar written by + CookieStore. app_webview holds the WebView's own cookie database, populated by + YouTubeSignInActivity. snaglite_yt holds visitor_data and sign-in state. None of + these may leave the device. +--> +<full-backup-content> + <exclude domain="file" path="yt-cookies.txt" /> + <exclude domain="root" path="app_webview" /> + <exclude domain="sharedpref" path="snaglite_yt.xml" /> +</full-backup-content> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Backup + device-transfer exclusions for API 31 and above. + Keep in sync with res/xml/backup_rules.xml (API 30 and below). +--> +<data-extraction-rules> + <cloud-backup> + <exclude domain="file" path="yt-cookies.txt" /> + <exclude domain="root" path="app_webview" /> + <exclude domain="sharedpref" path="snaglite_yt.xml" /> + </cloud-backup> + <device-transfer> + <exclude domain="file" path="yt-cookies.txt" /> + <exclude domain="root" path="app_webview" /> + <exclude domain="sharedpref" path="snaglite_yt.xml" /> + </device-transfer> +</data-extraction-rules> diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 017d909..9deb573 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,4 +1,5 @@ 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 + 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 new file mode 100644 index 0000000..91ecfa8 --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,56 @@ +[versions] +# Toolchain +agp = "8.13.2" +kotlin = "2.3.20" + +# SDK levels +compileSdk = "36" +minSdk = "26" +targetSdk = "36" + +# Libraries +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" +# Held at the 2.10 line deliberately, matching the lifecycle pin. 2.11.x is out, +# but its androidx siblings at that generation are the ones CLAUDE.md pins back for +# requiring compileSdk 37 — no reason to walk into that for a queue worker. +work = "2.10.5" + +[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-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" } +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" } + +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } + +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" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index e644113..eddabd2 100644 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index b82aa23..c42672d 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-9.7.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/android/gradlew b/android/gradlew old mode 100644 new mode 100755 index 97de990..249efbb --- a/android/gradlew +++ b/android/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,10 +15,12 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -27,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -112,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -170,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -200,18 +200,17 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/android/gradlew.bat b/android/gradlew.bat index 16e26a1..8508ef6 100644 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -1,92 +1,82 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% 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<int> 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<int> 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<State>(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<string> 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<string> 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<string>? ExtraArgs { get; init; } } public sealed class YtDlpResult @@ -26,7 +28,7 @@ public static async Task<YtDlpResult> 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<YtDlpResult> 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<YtDlpResult> 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<string> 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; } 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 @@ <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net10.0</TargetFramework> - <ImplicitUsings>enable</ImplicitUsings> - <Nullable>enable</Nullable> <AssemblyName>snaglite</AssemblyName> <RootNamespace>SnagLite</RootNamespace> - <InvariantGlobalization>true</InvariantGlobalization> <PublishSingleFile>true</PublishSingleFile> <SelfContained>true</SelfContained> <RuntimeIdentifier>win-x64</RuntimeIdentifier> @@ -19,9 +16,9 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="CliWrap" Version="3.10.1" /> - <PackageReference Include="Spectre.Console" Version="0.55.2" /> - <PackageReference Include="Spectre.Console.Cli" Version="0.55.0" /> + <PackageReference Include="CliWrap" /> + <PackageReference Include="Spectre.Console" /> + <PackageReference Include="Spectre.Console.Cli" /> </ItemGroup> <ItemGroup>