Repository navigation
deps: bump com.android.application from 8.13.2 to 9.3.2 in /android - #17
dependabot[bot] wants to merge 45 commits into
Conversation
New `.github/workflows/release.yml` (`workflow_dispatch` only) builds `snaglite.exe` and four debug APKs in parallel, renames assets with the chosen version tag, and attaches all five to a new GitHub Release via `softprops/action-gh-release@v2`. Inputs: `tag` (required), `prerelease` (bool), `notes` (Markdown; auto-generated from commits if blank). README gets a new "Cutting a release" runbook and updates the three "where to get binaries" paragraphs to point at the Releases page. CLAUDE.md gets a CI table describing all three workflows and a note on the release-signing upgrade path. Co-Authored-By: Claude Opus 4.7 <[email protected]>
Two bundled changes that together make Android downloads "just work":
1. 403 auto-recovery
- yausername library throws YoutubeDLException on yt-dlp non-zero exit
rather than returning Response.exitCode != 0, so the existing classification
branch never ran. Mirrored detection into both arms (catch + else).
- Split 403 detection from the SIGN_IN regex. 403 is almost always an
extractor-staleness symptom, not auth — routing it to sign-in misled users.
- On any YouTube 403 or sign-in/bot error, ytDlpFlow now runs one silent
YouTubeUpdater.updateNow + YouTubeBootstrapper.harvest then retries with
the fallback player_client chain and yt-dlp's native HTTP downloader
(aria2c amplifies googlevideo URL drift on retry). Sign-in state no longer
gates the fallback retry.
- Error card now surfaces "Update engine & retry" CTA for 403-class
failures, separate from the sign-in CTA.
2. Silent engine updates ("it just works" polish)
- EngineUpdateChecker.runSilentUpdateIfDue: no UI, wifi-only by default,
24 h-debounced. Fired from MainScreen LaunchedEffect (replacing the old
AlertDialog prompt) and from SnagLiteApplication.onCreate after
ensureEngines on setup-complete relaunches.
- ENGINE_CHECK_INTERVAL_MS: 3 d -> 1 d.
- SetupViewModel: ytdlp-update + visitor-harvest now run concurrently
via coroutineScope/async, cutting perceived first-launch latency.
- YouTubeHost.SIGN_IN_REGEX: dropped overly broad "This video is
unavailable" (matched private + region-locked + deleted videos that
signing in does not fix).
README updated. Verified on Lenovo Y700 g4 (Android 16): fresh install ->
Setup all-green -> first YouTube download silently succeeded via the 403
recovery path (refresh + retry, no UI).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
The Release workflow now runs assembleRelease and signs the four ABI APKs with the keystore in SNAGLITE_KEYSTORE_B64 (base64-decoded into $RUNNER_TEMP at job start), gated by a fail-fast check on the secret so a missing keystore halts the run instead of silently shipping a debug-signed build. README and CLAUDE.md drift from the May 2026 R8 re-enable + this signing flip is fixed in the same pass: release asset filenames now say -release.apk, the "R8 currently disabled" notes are gone, and the "release signing not yet wired" paragraph in CLAUDE.md is replaced with a description of the actual decode-step flow. Co-Authored-By: Claude Opus 4.7 <[email protected]>
GitHub's release UI displays assets alphabetically. Adding the "android-" infix on APKs and renaming "win-x64" to "windows-x64" makes the four APKs occupy the first four positions in the asset list, with the Windows .exe last. Co-Authored-By: Claude Opus 4.7 <[email protected]>
Workflow dispatches with no notes argument previously produced a release page whose entire body was the auto-generated "Full Changelog" link. Write a sectioned default body (tagline + asset list + quick start) when inputs.notes is blank; user-supplied notes still take precedence. softprops still appends the auto-generated "What's Changed" section after our body. Co-Authored-By: Claude Opus 4.7 <[email protected]>
CLI was pure yt-dlp passthrough with zero anti-bot hardening while Android shipped a full mitigation stack. Port the subset that works without WebView or browser cookies: - YouTubeHost — host classifier + 403/sign-in stderr regex, byte-identical to the Kotlin original. - YouTubeArgsInjector — injects Android Chrome UA, Accept-Language, and --extractor-args youtube:player_client=tv_simply,default,mweb (or web_safari,android_vr,mweb on fallback) with formats=missing_pot. No visitor_data (no WebView on Windows), no cookies (matches the CLI's existing "no creds" policy). - EngineUpdateChecker — 7-day debounced silent yt-dlp refresh on every snaglite get, mirroring Android's weekly check. State at %APPDATA%\SnagLite\engine-update.json. - ToolResolver — new SilentRefreshYtDlpAsync (atomic .new swap, no Spectre UI) for the background path. - YtDlpRunner — new ExtraArgs option spliced into the args list; stderr is now accumulated in a StringBuilder so 403 detection works even when benign trailing lines follow the error. - DownloadCommand — kicks engine check on entry, awaits with 10 s cap before exit. On YouTube 403 or sign-in stderr, runs an engine refresh and retries once with the fallback player_client chain plus --no-aria2 (googlevideo URL drift amplifies under multi- connection downloaders). - README — Stays-current bullet now reflects auto-update on both builds; CLI troubleshooting gets the 403 auto-recovery bullet. Smoke-tested: snaglite where (tools resolved from cache), YouTube download (dQw4w9WgXcQ, 243 MB MP4, primary chain held — no 403), engine-update.json written + debounced on second run. Co-Authored-By: Claude Opus 4.7 <[email protected]>
Auto Backup was uploading filesDir wholesale, which includes the Netscape-format cookie jar CookieStore writes for yt-dlp, plus the WebView cookie database under app_webview. Both now excluded from cloud backup and device transfer, on API 30-and-below and 31-plus rule formats alike.
remember{} ties a ViewModel to the composition, so every configuration
change built a new one and leaked the old one's viewModelScope. Use
viewModel() so the default SavedStateViewModelFactory constructs and
retains them.
Now that SetupViewModel survives recomposition, guard start() so a
config change during setup does not launch a second engine init.
Settings was a boolean with no back handler, so back exited the app
instead of returning to the list.
No version changes: the resolved releaseRuntimeClasspath is identical before and after. This isolates the upcoming toolchain and library bumps to single-file diffs and gives Dependabot a catalog to read.
Kotlin 2.0 moved the Compose compiler into the Kotlin repo and shipped it as org.jetbrains.kotlin.plugin.compose, so composeOptions and its kotlinCompilerExtensionVersion are gone; the plugin version is now just the Kotlin version. Daemon heap raised to 4G because K2 OOMs at 2G and reports it as a vanished daemon rather than an OOM.
Compose BOM 2024.06 -> 2026.06.01 (Material3 1.4.0), activity 1.13.0, lifecycle 2.10.0, core 1.17.0, coroutines 1.11.0, OkHttp 5.4.0, Coil 3.4.0. Adds core-splashscreen 1.2.0 to the catalog for the next change; not yet wired up. Kotlin 2.2.20 -> 2.3.20 (and with it the Compose compiler plugin, which shares the version). Gradle resolves kotlin-stdlib to the highest version in the graph, and a stdlib newer than the compiler cannot be read — it surfaces as unresolved references to apply/emptyList in otherwise-correct files. AGP stays at 8.13.2, whose R8 8.13.19 supports Kotlin 2.3; Kotlin 2.4 would put R8, lint and the Compose plugin outside that envelope. compileSdk 34 -> 36 because activity, coil and okhttp-android all carry a compileSdk 36 AAR-metadata floor and the build fails at checkDebugAarMetadata without it. targetSdk stays 34 — that one forces edge-to-edge and moves only once the screens can survive it. core, lifecycle and Coil are pinned below their newest releases: core 1.19.0 needs compileSdk 37 + AGP 9.1, lifecycle 2.11.0 needs compileSdk 37 + AGP 9.2, and Coil 3.5.0 is built with Kotlin 2.4.0. This branch deliberately stays on AGP 8.13.2. Compose is unaffected — BOM 2026.06.01 is compose-ui 1.11.4, and the compileSdk 37 floor only arrives with Compose 1.12. youtubedl-android deliberately held at 0.18.1 — that one changes download behaviour and needs its own manual matrix. LocalClipboardManager is deprecated in this Compose version; paste now goes through LocalClipboard.getClipEntry() on a coroutine.
The window theme was android:Theme.Material.Light.NoActionBar, hardcoded light, so every cold launch in dark mode flashed white before Compose painted. Window background now resolves per-configuration, and the launcher activity uses the androidx SplashScreen compat layer instead of the system-synthesized one.
Screens were bare Columns with hand-rolled title rows, so there was no place to hang window insets, no snackbar host, and no scroll behaviour. One SnagLiteScaffold now owns the top bar, the snackbar host and the inset padding, which is what makes the upcoming targetSdk 36 edge-to-edge enforcement a configuration change instead of a per-screen audit. Settings feedback moves from Toast to Snackbar; Android 12+ rate-limits toasts and they read as foreign next to Material 3.
Google Play requires targetSdk 36 for new apps and updates as of August 31 2026. compileSdk was already 36 (raised in the library bump to clear AAR-metadata floors); this moves targetSdk, which is the half that changes runtime behaviour. targetSdk 35+ makes edge-to-edge mandatory on Android 15+, so the Scaffold now consumes WindowInsets.safeDrawing and MainActivity calls enableEdgeToEdge() to give API 26-34 the same layout. Predictive back is on by default at targetSdk 36; no manifest flag needed. minSdk stays at 26.
Language level, nullable, analyzers and deterministic-build settings move to Directory.Build.props so a second project inherits them. Package versions move to Directory.Packages.props, which is also what Dependabot's nuget ecosystem reads. Analyzers are on at latest-recommended but warnings are not errors: there is no test suite here, so an analyzer-driven build break would block work with nothing to justify it.
Nothing here noticed when a dependency went stale, which is how the Android toolchain reached a two-year lag. AndroidX and Compose updates are grouped so they land as one PR rather than fifteen. youtubedl-android is excluded on purpose: bumping it changes download behaviour and needs the manual URL matrix, not an automated PR.
Dependabot assigns a dependency to the first matching group, not every matching group. With androidx (androidx.*) listed first it swallowed every androidx.compose* artifact and the compose group never received a member, so the config did not do what its own commit message claimed. Narrow pattern now comes first.
Records the version catalog as the single source of Android versions, why AGP 9 is deliberately skipped, the backup-exclusion invariant for the cookie jar, the one-Scaffold rule, and the WorkManager gap as known-and-out-of-scope rather than undiscovered.
Two changes that were individually correct combined into a dead button.
Scoping SetupViewModel with viewModel() retained it in the Activity's
ViewModelStore, so it now outlives the composition; the `started` guard
added to stop a configuration change re-entering a run then also blocked
the only other way SetupScreen is composed twice — Settings → "Re-run
initial setup".
The result was a one-frame flicker back to the download list, and
because start() bailed before reaching setSetupComplete(true), the pref
stayed false and the setup screen reappeared unrequested on the next
cold launch.
start() now treats an already-finished run as a re-run request: reset
the step list to Pending, clear `started`, and fall through. The
in-progress case the guard exists for is unchanged.
SetupScreen also had to stop keying its completion effect on
`state.finished`. collectAsState updates its State a dispatch after the
StateFlow changes, so an effect reading the snapshot would see the stale
`true` on the first frame of the re-run and call onDone() before the
reset landed. Awaiting the flow directly with `first { it.finished }`
reads the value start() has already reset.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_016HgzaN91n4oYiB7tRHYiCG
YouTubeSignInActivity is the app's only View-based screen, so it was missed when the branch moved everything else to edge-to-edge Compose scaffolds. With targetSdk 36, Android 15+ renders every window in the app edge-to-edge and ignores setDecorFitsSystemWindows, so the header — "Sign in to YouTube" and the Close button, with only 24 raw pixels of padding — drew behind the status bar, and the bottom of Google's login page, where its Sign in / Next button lives, drew behind the navigation bar. Apply systemBars() | displayCutout() insets as padding on the root container, added to whatever padding it already has so any design padding survives. The insets are returned unconsumed: nothing below the root applies system-bar padding, so there is no double-application to avoid, and CONSUMED would also swallow Type.ime() and stop the WebView reacting to the keyboard the login form needs. On API 26-34 the decor still fits system windows, so the listener sees zeroed insets and the layout is unchanged there. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016HgzaN91n4oYiB7tRHYiCG
values/themes.xml parented Theme.SnagLite on android:Theme.Material. NoActionBar — the dark platform theme — on the reasoning that dark is the safer choice for the few frames before Compose paints. But the same style pins android:windowBackground to @color/snaglite_window_background, which in this resource folder is the day-mode #FFFBFE. The platform theme's text colours and the window background therefore disagreed on API 26-28 in light mode. Compose screens never noticed, because they take their colours from MaterialTheme. YouTubeSignInActivity is a plain View hierarchy whose TextView and Button resolve ?android:textColorPrimary from this theme, so its label and Close button rendered near-white on near-white. Switch to android:Theme.Material.Light.NoActionBar. There is no DayNight platform parent before API 29, so night-mode correctness comes from values-night/colors.xml supplying the dark background, and API 29+ is unaffected either way because values-v29/themes.xml overrides the whole style with a real DayNight parent. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016HgzaN91n4oYiB7tRHYiCG
Raising targetSdk to 36 opted the app into the Android 15 foreground- service quota: a dataSync service gets roughly 6 hours of cumulative runtime per 24-hour window, after which the system calls Service.onTimeout(). An app that does not stop promptly is killed with ForegroundServiceDidNotStopInTimeException, and further starts throw ForegroundServiceStartNotAllowedException until the quota refills. At targetSdk 34 none of this applied, so nothing on the branch handled it. Override both onTimeout forms — the API 35 two-argument one that dataSync actually receives, and API 34's single-argument shortService one for symmetry — behind @RequiresApi, and stop foreground plus stopSelf from both. Logged at warn with the quota named so the condition is recognisable from logcat. This is a crash guard only; in-flight downloads still live in DownloadController's in-memory queue and do not survive the service going away. Noted against the existing WorkManager out-of-scope entry in CLAUDE.md, along with two stale Coil references in the same file (the pinned version now lives only in libs.versions.toml, and the 3.5.0 symptom is first-hand from this branch's Kotlin 2.3.20 migration). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016HgzaN91n4oYiB7tRHYiCG
README said the app "supports the predictive back gesture on Android 14+". That is only true of back-to-home. MainScreen uses BackHandler, which registers a plain OnBackPressedCallback with no progress channel — while enabled it tells the system the app handles back itself, which suppresses the cross-activity preview animation and supplies no in-app replacement. An animated peel out of Settings would need PredictiveBackHandler, which this branch did not add. Describe what the code actually does instead. Also drop MainScreen's unused PaddingValues import (the content lambda's parameter type is inferred) and un-interleave the imports that had kotlinx.coroutines.launch sitting inside the androidx.compose block, and fix an "a analyzer-driven" typo in Directory.Build.props. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016HgzaN91n4oYiB7tRHYiCG
The test device came back, so matrix items 8-11 were run against the release APK rather than inferred from a green build. All four passed, along with the release smoke test, the setup flow, the Snackbar migration, RTL and an end-to-end download. Three checks stay unverified and are named rather than quietly dropped: the API 26-28 base theme needs an emulator, the dataSync timeout cannot be provoked from adb, and predictive-back cancel needs a human thumb. Also records one finding the run turned up: YouTubeSignInActivity leaves the system bar icons at their default appearance, so in light mode they sit near-white on a near-white page. Insets are correct either way. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The activity never called enableEdgeToEdge(), so it kept the platform default icon appearance while its own window background follows the DayNight theme. In light mode that put near-white status bar icons on a near-white page, and near-white navigation bar icons too once the device was in three-button mode. Dark mode happened to line up and hid it. MainActivity already makes this call, so the Compose screens were always fine; this is the app's only View-based screen and it was missed when targetSdk moved to 36 and the system stopped drawing opaque bars. Verified on device across light and dark, gesture and three-button. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Patch release. Every other dependency on both platforms is already at its newest usable version: the Android catalog is current except for the three versions CLAUDE.md pins deliberately, and AGP 8.13.2 is the head of the 8.x line. Spectre.Console is deliberately left at 0.55.2 even though 0.57.2 exists. Spectre.Console.Cli has not followed - its newest release is still 0.55.0 and it declares a dependency on Spectre.Console 0.55.0 - so bumping only the renderer would run the CLI package against an API it was never compiled against, for no gain. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The old hint claimed three things in one line, and put the swipe instruction inside the empty branch - so the one non-obvious gesture in the app was advertised only while there were no cards to swipe, and hidden the moment there were. The swipe hint is gone; the red reveal behind the card carries it, and swiping still opens a confirmation. The empty state is now a heading over a body that names where files land, which nothing on this screen answered before, and its instruction order matches the screen: link, format, Download. The concurrency limit moves out of the empty state to a note above the list, shown only while something is actually waiting. The count comes from DownloadController.MAX_ACTIVE through pluralStringResource rather than a literal, so the copy cannot outlive a change to the constant. Every user-facing literal on MainScreen moves to strings.xml, including both content descriptions. Nothing outside this screen is touched. Verified on device: both sides of the queue-note branch, swipe removal, and a re-run of the RTL check, which string extraction is exactly the kind of change that can break. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Before API 29 there is no DayNight platform parent, so values/themes.xml pins a fixed Light one. That is right in day mode and wrong in night: ?android:textColorPrimary stayed near-black while the window background followed the -night colour qualifier to #1C1B1F, leaving the "Sign in to YouTube" label near-black on near-black. Only YouTubeSignInActivity is affected - it is the app's one View hierarchy, and every Compose screen takes its colours from MaterialTheme instead. The colour qualifier alone could never have fixed this: it moves windowBackground and nothing else. The comment in values/themes.xml claimed otherwise and is corrected here. values-night-v29/themes.xml is not redundant. 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+ in night mode and silently swap DeviceDefault.DayNight for a fixed dark parent. The duplicate exists to win those API levels back and has to stay in sync with values-v29. Verified on an API 28 emulator and on API 35 hardware, in both modes: the regression is gone below 29, and 29+ still resolves the DayNight parent - its Close button keeps DeviceDefault's sentence case rather than falling back to Material's all-caps, which is what a precedence break would look like. Release APK rebuilt and re-smoke-tested afterwards. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The service notification already carried a percentage, but only inside its content text - it never called setProgress, so the shade showed a number buried in a line rather than a bar. setProgress takes an indeterminate flag as well as a value. That matters here because the longest phase of a YouTube download is challenge-solving before any percentage exists, and a bar parked at zero through it reads as a stall rather than as work. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A download that 403s late in its transfer restarted from zero instead of resuming. Two causes, both fixed here. The YouTube auto-recovery path retried with skipAria2c=true, swapping the external downloader for yt-dlp's native one. The two keep incompatible partial state - aria2c writes the target file plus a sibling .aria2 control file, the native downloader resumes from .part - so --continue found nothing it recognised and refetched everything. The retry now keeps aria2c, and the comment records why, since skipping it was a deliberate choice originally made to limit URL drift. The retry also called onProgress(0f, ...), reporting a restart that no longer happens. A negative percent now means "status line only, keep the percentage", so the bar holds its position while the engine refreshes. This is most likely to bite near the end of a long transfer, because YouTube's CDN URLs are signed and time-limited - and backgrounding the app stretches the transfer, which is why switching apps made it worse. Separately, working directories move from cacheDir to filesDir. Resuming depends on those partial files still existing, and Android reclaims cacheDir under storage pressure - preferentially while the app is backgrounded and against the largest files, which is exactly a near-complete download. Nothing reclaims filesDir for us, so successful publishes now sweep their own directory; remove() and clearFinished() already did. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three lines of left-aligned prose read as a wall of text on an otherwise empty screen. Reduced to a heading and one short line, centred as a block with room above it. The save locations move out of it. They were accurate but they are reference material, not a first-run instruction, and finished cards already say "Saved to gallery". Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The queue existed only as a MutableStateFlow, so anything that took the process took the whole list with it. A force-stop wiped four queued items instantly during testing, with no trace on relaunch - and on a device with an aggressive vendor battery manager that is the ordinary case, not an edge one. QueueStore writes the list to filesDir/queue.json after each change and DownloadController reads it back in its constructor. The read is synchronous: an async restore would flash an empty list and then fill it, which reads as "my downloads were lost" for exactly as long as it takes to notice. Writes go through a temp file and a rename, so a kill mid-write cannot leave a truncated file that fails to parse next launch. Progress is deliberately not persisted. A restored item has no process behind it, so a percentage would describe work nobody is doing. In-flight items come back as Queued and resume through yt-dlp's --continue against the work directory, which is why those had to move off cacheDir first. Restored items are not started from the constructor. pumpQueue starts a foreground service and the constructor can run while the app is backgrounded, which Android 12+ answers with ForegroundServiceStartNotAllowedException. MainViewModel.init calls resumeRestored() instead, where the foreground start is legal - so a killed download resumes when the user next opens the app. Uses org.json rather than kotlinx.serialization: the payload is a flat list of small objects, and the alternative costs a dependency plus a compiler plugin on a toolchain CLAUDE.md documents as sensitive to exactly that. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The swipe-to-remove background is a plain rectangle drawn behind a card whose shape is MaterialTheme.shapes.large, so its errorContainer colour showed through at all four corners of every row - even at rest, with nothing being swiped. Clipping the background to the same shape leaves the reveal doing its job only while a card is actually moving. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The app followed the system setting with no way to override it. The choice lives in SnagLitePrefs as a stable string rather than an enum ordinal, so reordering ThemeMode later cannot resettle anyone's pick. The StateFlow lives on the Application 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 immediately rather than on next launch. It is seeded in onCreate, not as a field initializer. Field initializers run in the Application constructor before attachBaseContext, so reading SharedPreferences there dereferences a null base context and kills the process at the splash screen - and because installCrashHandler has not run yet, it leaves no crash dump to explain why. The pre-Compose window background still follows the system setting, so a cold launch under an override briefly shows the system-themed frame. Settings says so rather than leaving it as a surprise. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The version was a hardcoded string in SettingsScreen, free to drift from versionName in build.gradle.kts. It now reads BuildConfig.VERSION_NAME, which needed buildConfig = true since AGP 8 defaults it off. Bumped to 1.1.0 with versionCode 2. The name had never moved off 1.0 despite the app gaining a persisted download queue, notification progress and a theme toggle; versionCode has to increase alongside it for any install-over-the-top. Also drops the full stop from the byline. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Failures showed yt-dlp's stderr verbatim - a 400-character tail of a developer log, ERROR: prefix included, cut wherever the limit landed. Alongside it sat a couple of hand-written, actionable messages, so the same app spoke in two registers depending on which path failed. DownloadErrors maps known failures to one plain line each and keeps the raw text as Error.detail, shown behind a Details toggle on the card rather than in its body. Nothing is discarded; it just stops being the headline. The classifier is conservative on purpose. Matching another project's error strings means matching something that changes between releases, and a confidently wrong message is worse than an opaque one - so anything unrecognised falls through to "Download failed" with Details still available. Ordering matters more than it looks. "Unable to download webpage" is yt-dlp's wrapper around any fetch failure and prefixes 404s as readily as real connectivity problems; testing a missing page produced a confident "check your network" until every HTTP status was moved ahead of it. That branch no longer matches the wrapper at all. QueueStore persists detail too. Without it the Details toggle vanished on the next launch - the moment someone interrupted mid-download is most likely to go looking for it. Also retires "Saved to cache but could not publish to the gallery": it named an internal concept, and stopped being true when work directories moved to filesDir. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two reliability fixes that overlap in DownloadController, so they land together rather than as a split that misrepresents either. Generic path: four distinct failures all arrived as yt-dlp's flat "Unsupported URL", which reads as "this site will never work" regardless of what actually went wrong. They now separate: - A configured wrapper host whose page had no iframe says the page had no player on it, rather than blaming the site. - Unwrapping to an embed no resolver claims names the player as the unsupported part, since that is what would need support added - not the listing page the user pasted. - Audio-only against a resolver-backed host says so outright. Resolvers return a combined mp4, so this could never work, and it previously failed several seconds later as an unsupported site. - Resolver failures distinguish "matched but could not extract" from "extracted but the fetch failed", and stop naming internal resolver letters in the headline or pointing at a "Update yt-dlp now" control that is actually labelled "Update download engine". Each carries a detail string, and DownloadResult.Failure gaining that field is also the signal for DownloadController to skip classification: running the yt-dlp string matcher over our own prose is a chance to mislabel a message already written for a person. Wake lock: ensureLocks only acquired when the lock was not already held, so the two-hour timeout could lapse mid-download. It mostly self-healed, since this runs on every state transition and a live download ticks constantly - but a stalled download stopped calling back in, leaving an expired lock unrenewed for the length of the stall, which is when the CPU most needs to stay up. Acquiring unconditionally restarts the timeout, so the deadline stays ahead of active work; it remains a leak guard for a process that dies without releasing. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
DownloadQueueWorker replaces DownloadService. 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. One worker for the queue, not one per download. Per-item workers would each fight over the same notification id and would need MAX_ACTIVE reimplemented on a WorkManager that has no per-tag parallelism control, while gaining nothing: downloads already run capped on the application scope. So the worker holds the foreground service, publishes the aggregate notification, and waits for the drain. Running in-process, it reaches DownloadController directly, so progress still flows through the existing StateFlow and no UI code changes - MainScreen and DownloadItemCard are untouched. ExistingWorkPolicy.KEEP, not REPLACE: every enqueue path calls ensureRunning, so REPLACE would cancel the foreground service each time a second item was added. The manifest merges foregroundServiceType onto WorkManager's SystemForegroundService, which declares none of its own. From API 34 a typed permission requires a typed service, and without it startForeground throws MissingForegroundServiceTypeException on the first download. WorkManager is held at the 2.10 line to match the lifecycle pin; its 2.11 siblings are the ones CLAUDE.md pins back for needing compileSdk 37. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The out-of-scope entry still described DownloadService and listed the migration as pending, which would have misled the next session in both directions. Documents what was measured on hardware rather than what WorkManager promises: reboot-then-unlock resumes unattended, but a pre-unlock reboot cannot resume at all because Direct Boot leaves queue.json, the partials and WorkManager's own job database in locked credential-encrypted storage, and force-stop cannot resume until the app is opened because stopped=true blocks every component. Those are OS constraints, not things to try fixing again later. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
DownloadQueueWorker's KDoc named an expedited worker's ability to start a foreground service from the background as the reason the migration off DownloadService was worth doing, but ensureRunning built a plain OneTimeWorkRequest. A background enqueue therefore landed as ordinary work the scheduler was free to defer, which is the case the migration existed to fix. The fallback is RUN_AS_NON_EXPEDITED_WORK_REQUEST rather than DROP_WORK_REQUEST. Expedited quota is finite per 24 hours, and a queue drain that starts late is an inconvenience while one that is dropped is a queue the user has to notice and restart by hand. A failed setForeground is no longer a log line either. 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. doWork now returns Result.retry(); by the next attempt the app may be foreground or the Android 15 dataSync quota refilled, and nothing in flight is cancelled because those coroutines are not the worker's to stop. The throttled progress refresh still swallows its failures - one dropped notification tick is not worth a retry. Also drops the notification block DownloadController was still building and discarding. The worker has owned the notification since the migration; the controller's copy outlived its callers, so refreshServiceLifecycle is now only the locks plus the guarantee that a worker exists, and the misnamed bumpServiceNotification becomes refreshLocksThrottled - refreshing the wake lock's timeout is all it still does on a progress tick. Verified with a release build, R8 and the resource shrinker included. The device matrix is not run: no hardware attached. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016MZecrMpAJkPqzRzmBGBA6
The previous commit returned Result.retry() as soon as setForeground failed, which put the early return ahead of pumpNow(). That traded one failure mode for a worse one: on a device that persistently refuses a background foreground-service start, the queue would never drain at all, where the old DownloadService pumped regardless and merely logged the failure. pumpNow() now runs unconditionally and the retry follows it. A queue draining without a foreground service behind it is worse than draining with one, and far better than not draining. WorkManager still re-runs the worker with backoff to try the foreground start again, and the downloads the pump just started are unaffected either way - they live on the application scope, not on the worker. Found while running the device matrix on the TECNO KM7k rather than by reading the diff. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016MZecrMpAJkPqzRzmBGBA6
QueueStore collapsed Queued, Preparing, Running and Paused alike to Queued on restore. The reasoning held for the first three - their process is gone by the time the file is read back, so any other state would describe work nobody is doing - but Paused is not a process state. It records a decision the user made, and a decision outlives the process that was serving it. The effect was that pausing a download only lasted until the next process restart, after which it resumed on its own. That is worst precisely where it matters most: someone who pauses a large download is usually doing it because they are on metered data, and the silent resume spends exactly the money they were trying not to spend. Paused now round-trips through queue.json as itself. The percentage is still dropped, per this object's existing rule - the partial file is on disk and --continue will pick it up, but nothing has measured it since the process died, so a remembered number would be a guess dressed as progress. Observed on the TECNO KM7k while cleaning up after the device matrix: two deliberately paused items restarted by themselves after a reinstall. Release build passes. Not yet exercised on device - USB dropped before the pause/kill/reopen check could run. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016MZecrMpAJkPqzRzmBGBA6
…nger exists The queue-persistence and WorkManager work landed without the README moving with it, so the user-facing docs had drifted from wrong-by-omission into wrong: - Queue persistence across process death was listed under "Out of scope" as in-memory only, with queued and paused items lost on an OS kill. QueueStore has mirrored the queue to disk since df54567. Replaced with what actually happens, including the two cases that genuinely cannot recover - a reboot never unlocked, and a manual force stop. - The work directory was documented at <cacheDir>/snaglite-dl/. It moved to filesDir precisely so the OS could not reclaim a near-complete download, which is the detail worth stating rather than the path alone. - The foreground service is a WorkManager worker now, in the summary line and in the feature list. - Playlists were listed as out of scope for Android. Android has supported YouTube playlists for some time via YouTubePlaylist.enumerate; only batch input files are out of scope. Also documents that a paused download stays paused across process death, per the previous commit, and adds queue.json and the work directory to the on-device paths table. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016MZecrMpAJkPqzRzmBGBA6
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01UPPk9fTXP6zVx5yk77jmkb
Bumps com.android.application from 8.13.2 to 9.3.2. --- updated-dependencies: - dependency-name: com.android.application dependency-version: 9.3.2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <[email protected]>
|
Closing: AGP 9.x is deliberately not adopted yet. It removes the org.jetbrains.kotlin.android plugin and turns on R8 strict full mode for keep rules; that migration needs its own branch and on-device testing (see CLAUDE.md, Toolchain versions). The project stays on AGP 8.13.2 / Gradle 8.13 for now. |
|
OK, I won't notify you again about this release, but will get in touch when a new version is available. If you'd rather skip all updates until the next major or minor version, let me know by commenting If you change your mind, just re-open this PR and I'll resolve any conflicts on it. |
Bumps com.android.application from 8.13.2 to 9.3.2.
You can trigger a rebase of this PR by commenting
@dependabot rebase.Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
@dependabot rebasewill rebase this PR@dependabot recreatewill recreate this PR, overwriting any edits that have been made to it@dependabot show <dependency name> ignore conditionswill show all of the ignore conditions of the specified dependency@dependabot ignore this major versionwill close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)@dependabot ignore this minor versionwill close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)@dependabot ignore this dependencywill close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)