Skip to content

deps: bump gradle-wrapper from 8.13 to 9.7.1 in /android - #18

Closed
dependabot[bot] wants to merge 45 commits into
mainfrom
dependabot/gradle/android/gradle-wrapper-9.7.1
Closed

dependabot[bot] wants to merge 45 commits into
mainfrom
dependabot/gradle/android/gradle-wrapper-9.7.1

Conversation

@dependabot

@dependabot dependabot Bot commented on behalf of github Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Bumps gradle-wrapper from 8.13 to 9.7.1.

Release notes

Sourced from gradle-wrapper's releases.

9.7.1

The Gradle team is excited to announce Gradle 9.7.1.

This is a patch release for 9.7.0. We recommend using 9.7.1 instead of 9.7.0.

Here are the highlights of 9.7.0 release:

  • Isolated Projects graduates to incubating
  • Broader Configuration Cache compatibility
  • Resilient Sync helps you fix broken builds
  • More source locations in problem reports

Read the Release Notes

We would like to thank the following community members for their contributions to this release of Gradle: Adam, Aman Gautam, Aman Kumar, Anton Dubrouski, Aurimas, gbhavya07, Josh Friend, nicklauslittle-gov, Pragati, project516, Qin Mi, Ravi, sk-reddy17, Suvrat Acharya, Yongshun Ye.

Upgrade instructions

Switch your build to use Gradle 9.7.1 by updating your wrapper:

./gradlew :wrapper --gradle-version=9.7.1 && ./gradlew :wrapper

See the Gradle 9.x upgrade guide to learn about deprecations, breaking changes and other considerations when upgrading.

For Java, Groovy, Kotlin and Android compatibility, see the full compatibility notes.

Reporting problems

If you find a problem with this release, please file a bug on GitHub Issues adhering to our issue guidelines. If you're not sure you're encountering a bug, please use the forum.

We hope you will build happiness with Gradle, and we look forward to your feedback via Twitter or on GitHub.

9.7.0

The Gradle team is excited to announce Gradle 9.7.0.

... (truncated)

Commits

Dependabot compatibility score

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 rebase will rebase this PR
  • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
  • @dependabot show <dependency name> ignore conditions will show all of the ignore conditions of the specified dependency
  • @dependabot ignore this major version will 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 version will 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 dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)

Note
Automatic rebases have been disabled on this pull request as it has been open for over 30 days.

corecompiled and others added 30 commits May 19, 2026 23:01
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]>
corecompiled and others added 15 commits August 4, 2026 02:06
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
Bumps [gradle-wrapper](https://github.com/gradle/gradle) from 8.13 to 9.7.1.
- [Release notes](https://github.com/gradle/gradle/releases)
- [Commits](gradle/gradle@v8.13.0...v9.7.1)

---
updated-dependencies:
- dependency-name: gradle-wrapper
  dependency-version: 9.7.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
@dependabot dependabot Bot added dependencies Pull requests that update a dependency file java Pull requests that update java code labels Aug 24, 2026
@corecompiled

Copy link
Copy Markdown
Owner

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.

@dependabot @github

dependabot Bot commented on behalf of github Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

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 @dependabot ignore this major version or @dependabot ignore this minor version. You can also ignore all major, minor, or patch releases for a dependency by adding an ignore condition with the desired update_types to your config file.

If you change your mind, just re-open this PR and I'll resolve any conflicts on it.

@dependabot
dependabot Bot deleted the dependabot/gradle/android/gradle-wrapper-9.7.1 branch October 14, 2026 02:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file java Pull requests that update java code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant