# Changelog

All notable changes to **Flipper the Ripper** are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

**This file is the release notes.** The release workflow cuts the `## [x.y.z]` section for the tag
being built out of this file and publishes it as the GitHub release text (`scripts/release-notes.sh`);
a tag without a section here fails the workflow. A unit test (`ChangelogTest`) checks that the
section for the version in `app/build.gradle.kts` exists before anything is tagged.

## [Unreleased]

## [1.14.0] - 2026-09-26

Updates install from inside the app — no browser, no download that hangs at 100 %.

### Added
- **Updates install from inside the app.** *Install update* on the Home card, or a tap on the update
  notification, downloads the newest APK in the app — from the product page's own copy, GitHub as the
  fallback — resumes an interrupted download from the last byte, checks the file against the published
  SHA-256 (a mismatch is deleted, never installed) and hands it to Android's package installer. Android
  asks once to allow *Install unknown apps* for the app; the update continues by itself when you come
  back. Browsers held the APK back behind a "file might be harmful" question that could stay hidden in
  a browser opened from another app, so the download hung at 100 % (seen on a Galaxy S24). If the
  in-app update fails, the card offers the browser download instead. After the update a notification
  offers to reopen the app, because Android ends it while replacing it. New permission:
  `REQUEST_INSTALL_PACKAGES` (see SECURITY.md).

### Fixed
- The French update notification showed a stray `\1` instead of its semicolon (since 1.13.0).

## [1.13.0] - 2026-09-26

The app now tells you when a new version is out — also while it is closed.

### Added
- **A notification when a new version is out.** Until now a new release showed up only as a card on
  the Home screen — so only for people who opened the app. A background check now runs twice a day
  (only with a network, also while the app is closed), asks the website's `latest.json` first and
  GitHub as a fallback (no 60-requests-per-hour limit to run into), and posts one notification per
  release on its own *App updates* channel. Tapping it downloads the newest APK; *What's new* opens
  the release notes. A notification that could not be shown (permission not granted yet) is tried
  again on a later check. It can be switched off in *Settings → Behavior*. Devices that cannot install
  the releases (32-bit only) are never checked.

## [1.12.0] - 2026-09-26

An Instagram reel that needs an account now offers the sign-in where it fails — and retries by itself.

### Added
- **Sign in to Instagram, right at the error.** A reel that is only visible to signed-in accounts used
  to fail with an English sentence pointing at *Settings → Instagram*; the user had to find the
  sign-in there, come back and press Retry. Now the failed card — and the Home screen after *Load
  info* or *Download* — says what happened in the user's language and offers **Sign in to Instagram**.
- **A three-step sign-in wizard:** ① what signing in changes and what happens to the password (it is
  typed on Instagram's own page; the app keeps only the session, like a browser), ② Instagram's login
  page, ③ confirmation. Once Instagram sets its session the wizard moves on by itself and **the failed
  download starts again automatically**; from Home the link's info is loaded again. *Not now* leaves
  everything as it was. Already signed in when it opens, it goes straight to the end.
- The offer only appears when it can help: Instagram, a sign-in error, and no session yet. A signed-in
  account that still cannot see a reel gets the plain error.

## [1.11.0] - 2026-09-25

Five languages, an Undo for the swipe, and a home page that always has the newest APK.

### Added
- **Spanish, Italian and French.** The app now speaks five languages. On Android 13+ the language
  can also be chosen for the app alone (*Settings → Apps → Flipper the Ripper → Language*).
- **Undo after deleting.** Swiping a finished download away (or deleting it) shows a snackbar with
  *Undo*, which puts the entry back exactly as it was. Downloads still in the queue offer no undo:
  deleting them also cancels them.
- **Product page** at [flipper-the-ripper.celox.io](https://flipper-the-ripper.celox.io) (also
  `flippertheripper.celox.io` and `ftr.celox.io`). It always offers the newest APK;
  <https://flipper-the-ripper.celox.io/download> is a stable direct link. Settings → About links to it.

### Changed
- Gradle names its APKs like the published file (`flipper-the-ripper-v1.11.0.apk`,
  `…-debug.apk`) instead of `app-arm64-v8a-release.apk`. The release workflow takes the file under
  the tag's name, so a tag that does not match `versionName` fails the job instead of publishing a
  mislabelled APK.

## [1.10.0] - 2026-09-21

Downloads now run **one at a time, in the order you see**, and you can say what quality you want.
Both were measured on a device before and after; the numbers are below rather than adjectives.

### Added
- **A queue that is real.** Until now every download was its own background job and Android ran
  several at once, so "queued" was a label with nothing behind it — what started next was whatever
  the scheduler picked, and the order in the list was decoration. One runner now drains the queue in
  order. Measured: never more than **1** download active across 80 samples over 160 s, where two
  used to run in parallel.
- **Pause and resume.** Pausing keeps what has already been fetched and resuming continues from that
  byte. Measured: paused at 52,345,281 bytes, resumed at 59,932,610 and carried on — not a restart.
  A paused download keeps its place in the queue.
- **Reorder by dragging.** Waiting downloads carry a handle; the running one deliberately does not,
  because moving it would mean stopping it. The new order is what actually runs next — verified end
  to end on the device.
- **Swipe a finished download away** to clear it from the list. Only finished ones: swiping away
  something still downloading would be an accident waiting to happen, and the card carries Cancel.
- **One notification for the batch** — "Downloading… · 2 of 5" — instead of one per download.
- **Quality picker.** The Download button gained a chevron that opens an options sheet:
  **Best · 1080p · 720p · 480p · Audio only**, with a line under the button saying which one will
  happen. After *Load info*, a tier the video does not reach is greyed out and the sheet names the
  actual maximum. Measured on one video: **480p → 38.8 MB, 720p → 161.2 MB.**
- **A remembered default quality** (Settings → Behaviour), used for shared links. The one-tap path
  stays one tap — the picker is an offer beside it, never a question in front of it.

### Changed
- **The Video/Audio toggle is gone.** "Audio only" is a quality tier in the same list, because that
  is the one decision being made. An existing Audio default is read forward, so nobody silently gets
  video back.
- The History list has two sections — what is in the queue, and what is finished.
- The card names the quality that was asked for, so several downloads of the same video are not a
  row of identical entries.

### Fixed
- **⚠️ The database would have deleted your download history.** It was built with a destructive
  fallback, meaning the *first* schema change — this one — would have wiped every entry without a
  word. There is a real migration now, and a test that writes a genuine old database and checks the
  rows survive.
- **A pause could be erased a moment after you tapped it.** Resolving a video's metadata takes
  3–11 s with the Pause button visible for all of it, and the phase written at the end of that
  overwrote the pause — the download carried on and, on the way out, deleted the partial file the
  pause existed to keep.
- **Resume started from zero.** Every engine opens a run by clearing its working directory, which
  deleted exactly that partial file. Measured before the fix: paused at 62,436,533 bytes, resumed
  at 0.
- **A download that was only waiting looked like one that was running** — spinner, moving progress
  bar and all. Defensible when a download started the instant you asked for it; untrue once there
  is a queue.
- Every YouTube link showed the word "watch" on its card until the title resolved.
- The server backend silently ignored the chosen quality and always returned best.

### Verified
- On the emulator with real downloads: queue order followed end to end including after a drag;
  pause/resume with the partial file measured at every step; the migration run against a database
  written by the previous version; the quality cap checked against yt-dlp's own format selection.
- 386 unit tests, 20 instrumented, every new pin mutation-probed.

### Not in this release
- Subtitles and playlist downloads were designed for 1.10.0 and **dropped before implementation**;
  nothing was built for either. The design notes are kept in `docs/specs`.
- **Known limitation:** if the ffmpeg merge fails, the existing self-heal retries with a single
  pre-muxed stream, and those top out well below 720p on YouTube. The download then succeeds at a
  lower resolution than the card says was requested. Observed once during verification.


## [1.9.2] - 2026-09-20

A shared link now always turns into a download you can see. Six defects behind that, all measured
on a device before anything was changed, all of them silent — nothing crashed and nothing logged.

### Fixed
- **A shared link was silently dropped when the app had last been on History or Settings.**
  Reproduced 5 times out of 5. The only consumer of the shared-link bus lived in `HomeViewModel`,
  which `hiltViewModel()` creates when the Home screen composes. After the system reclaimed the
  process, the NavHost restored whichever tab was last open — if that was not Home, the ViewModel
  was never built, nobody read the bus, and the link stayed in the channel: the app opened and
  nothing happened, with no error anywhere. That is why the behaviour looked random rather than
  broken; it depended on the tab you had used last. The consumer is now an application-scoped
  `ShareLinkHandler` that exists from process start, so no screen can be a precondition for a
  download.

  | App parked on | Process killed by the system | Before | Now |
  |---|---|---|---|
  | Home | yes | download starts | download starts |
  | History | yes | **link lost** | download starts |
  | Settings | yes | **link lost** | download starts |

- **The status ran backwards mid-download, and the loading animation went with it.** The worker read
  the record once, while it still said QUEUED, and later wrote that stale copy back twice with
  `REPLACE` — after metadata resolution and again at 100 % just before saving. Measured:
  `RUNNING/99.4 %` → `QUEUED/0 %`. The spinner is drawn for RUNNING, so it blinked out at exactly
  those two moments, the second time for the whole duration of the save. Both writes are now
  field-scoped updates that cannot touch the phase or the progress.

- **The real progress never reached the screen.** The database recorded it on every step
  (2 → 12 → 23 → … → 99) and the notification showed it as a determinate bar, but
  `DownloadEntity.toDomain()` dropped the column, so the History card could only ever draw an
  indeterminate wave. Card and notification now read the same field, and the card shows the
  percentage in its status line.

- **A finished download left no notification at all.** The completion notification was posted under
  the same id as the ongoing foreground notification, and WorkManager cancels that id when the
  worker ends — measured as zero notifications at +0 s, +2 s and +5 s after completion. Terminal
  notifications now have an id of their own.

- **Sharing a link left the app on the start screen for seconds with no sign of anything happening.**
  Measured 1.1 s warm and 9.3 s on a cold start between the share and the first visible evidence,
  because navigation waited for preferences, the database and WorkManager. The handler now runs from
  process start (no ViewModel to build first) and moves to History before enqueuing.

- **History answered a share with "No downloads yet."** Its list started as an empty list, which the
  screen cannot tell apart from "not loaded", so the empty state was drawn for 260–320 ms right
  after a share. Not-yet-loaded is now its own state.

### Added
- **Real phases: Preparing → Downloading → Finishing → Saved.** `RUNNING` used to cover four
  different things, including a 3–11 s metadata resolve where nothing downloads and an ffmpeg merge
  after the bar already read 100 %. Post-processing is detected from the engine's own status line
  (`DownloadPhases`), so a merge no longer reads as a finished download that is stuck. The loading
  indicator is shown for every in-flight phase, not only one of them.
- **The notification says what is happening and how far along it is:** phase as the title, video
  title as the text, the percentage beside them, and tapping it opens the app. Indeterminate while
  there is no measurable total, determinate as soon as there is — never an invented number.

### Changed
- The progress callback no longer blocks the engine's download thread on a database round-trip and a
  binder call (`runBlocking`, roughly a hundred times per file). It hands the value to a conflated
  channel; a slow write cannot slow the download, and the newest phase always wins.
- Cancelling covers every in-flight phase, via the domain rule rather than a hand-written list.

### Verified
- On the emulator, with real downloads: share from a cold start (8 of 8, first database row after
  1.2–2.4 s), from a running app, and after the system killed the process while parked on Home,
  History and Settings; the in-app Download button; an Instagram share (platform detected, typed
  `LoginRequired` — the media itself needs a logged-in session this emulator does not have).
- The full phase sequence on one file: QUEUED → PREPARING → RUNNING with rising measured percentages
  → PROCESSING → COMPLETED, with the notification following it (`Preparing…` indeterminate,
  `Downloading…` determinate with the video title and `0 %`…`100 %`, `Finishing…`, then
  `Download complete`, which now survives the worker ending).
- Leaving the app and killing its process mid-download: progress kept rising (4 % → 46 %) with the
  notification live throughout, and the card showed the same percentage on return.
- A failing link: QUEUED → PREPARING → FAILED with the engine's own message in the notification.
- Two downloads at once: independent progress, one notification each.
- Rotation, repeatedly and with a share intent still in the activity: no duplicate download.

## [1.9.1] - 2026-09-15

### Fixed
- **A shared link always shows its download.** Reported as "when I hand a download to the app via
  share, it must always be visible that the file is now being downloaded — show the History list and
  the animation at the top; at the moment I just see the start screen". That start screen was
  1.8.3's doing: a share stayed on Home with a snackbar, and what the user actually saw was an empty
  form (the state resets once the download is enqueued) and a message gone in four seconds. A
  download you cannot see is a download that may or may not be happening.
  A share with auto-download on now lands on **History**, where the new download is the top card,
  *Queued* or *Downloading…* with its progress wave — the row is written before the worker starts,
  the list is newest-first and scrolls to the top on a new entry, so the card is there the moment
  the screen appears. With auto-download off the share still opens Home with the link filled in;
  nothing is downloading yet, so the form is the right screen.
  The 1.8.3 snackbar is gone with it: buffered while History shows, it would have surfaced minutes
  later on Home about a download long finished.

### Verified
- On the emulator, from Home: share a link → History, new card on top, *Downloading…* with the
  wave; the same from Settings. Tapping Download by hand is unchanged.

## [1.9.0] - 2026-09-15

### Changed
- **One APK per release — the 32-bit build is gone.** Every release since 1.1.1 shipped two files,
  `arm64-v8a` and `armeabi-v7a`, and asked people to pick. Measured over all releases the 32-bit file
  drew **30 of 177** downloads, and half of those are a crawler pattern (exactly one per file across
  thirteen consecutive 1.2.x releases). A genuinely 32-bit phone on Android 7+ is a budget device
  from 2014–2016. Meanwhile picking the wrong file failed in three directions, all ending in
  *"App not installed"*: the 32-bit APK on a 64-bit phone with the app installed is a **downgrade**
  (it carries the lower version code); the 32-bit APK on a 64-bit-only device (Pixel 7+, Android 15)
  has no matching ABI; the 64-bit APK on a 32-bit phone has none either. One file removes the
  choice. **1.8.3 remains the last release for 32-bit devices**, and the app no longer shows an
  update notice on a device that cannot install a release (`UpdatePolicy`) — the alternative was a
  permanent nag nobody could act on.
- **Version codes keep the ×10 scheme** although only one ABI is built: every installed copy carries
  a code from it (1.8.3 is 282), and a plain 29 for 1.9.0 would be refused as a downgrade.
- **The GitHub release page shows the release notes.** Until now it showed a single "Full Changelog"
  link while the notes sat unread in this file. The workflow now cuts the tag's section out of
  `CHANGELOG.md` and publishes it, followed by an install block (one file, same key since 1.0.0,
  where to look if it will not install) and a verify block.
- **The release workflow verifies the signing certificate** of the built APK against the published
  digest and refuses to publish on a mismatch. A `SHA256SUMS.txt` is published alongside the APK.

### Fixed
- **SECURITY.md said no authentication tokens are stored. That has been false since 1.2.11**, when
  the optional Instagram sign-in began keeping the session cookie in the WebView cookie store — the
  README described it correctly, the security policy contradicted it. The policy now says what is
  kept, where, that it never leaves the device, and that *Sign out* expires it. The permission list
  gained the missing `ACCESS_NETWORK_STATE`; X and Dailymotion were missing from the platform lists
  in both `SECURITY.md` and `CONTRIBUTING.md`.
- README: the architecture diagram had none of the parts added since it was drawn (motion,
  share target, navigator, link bus, update coordinator; yt-dlp also serves X and Dailymotion).
  Troubleshooting gained the *"App not installed"* row with its three causes. The roadmap now
  shows what shipped. The signing certificate's SHA-256 is published — for a sideloaded app it is
  the only thing that ties a file to the project, and it was written nowhere.

### Added
- README sections **Testing** (what the suite is, the drift guards, the mutation rule) and
  **Changelog**; CONTRIBUTING describes the three test conventions the repo actually follows.
- Drift guards for the release path and the documentation: `ReleaseArtifactsTest` (ABI split ↔
  workflow ↔ README), `ChangelogTest` (a section for the version being built), `ReleaseNotesScriptTest`
  (the script cuts the right section and fails on an unknown tag), `SecurityPolicyTest` (permissions
  and platforms), `SigningDocsTest` (the published digest, checked against the keystore where it is
  present), `UpdatePolicyTest`. All mutation-probed.

## [1.8.3] - 2026-09-15

### Fixed
- **Sharing a link now opens Home, not History.** Reported as "when I share a link from another app
  to download it, History is shown — it should be Home". Reproduced on the emulator before changing
  anything: share a link, land on the list of past downloads.
  Auto-download on share is **on by default**, and that path ran straight into the ordinary download
  routine, which jumps to History to show the download running. Correct for the Download button — the
  user asked to see it by tapping — but wrong for a share: the share is what put the user in front of
  the app, and the screen a link belongs to is the one that takes links.
  A share now goes to Home first, always, whether or not it auto-downloads. The download still starts
  immediately; a message confirms it and says where it went, so staying on Home costs no feedback.
  History remains one tap away in the bar.
- **A share labelled as any other text type no longer goes missing.** v1.8.2 widened the intent filter
  to `text/*` so senders that do not label shared text as `text/plain` can reach the app — but the
  activity still checked for the exact subtype and dropped everything else. Such a share opened the
  app and then did nothing at all. Any `text/` subtype is accepted now.

### Changed
- Snackbar messages from Home carry a string resource instead of finished text. A message built in
  the ViewModel is English on every phone, German ones included.

### Verified
- On the emulator: with the app sitting on History, sharing a link lands on **Home** with the
  confirmation visible, in English and in German (`Start` / "Download gestartet — zu finden im
  Verlauf"), and the download runs. Tapping Download by hand still jumps to History.

## [1.8.2] - 2026-09-08

### Fixed
- **The app is easier to find when sharing a link.** Reported as "Flipper the Ripper cannot be
  selected when I tap share in TikTok". Measured first: the app *was* registered correctly and did
  resolve for `ACTION_SEND` + `text/plain` on the device, so the registration was never the fault —
  what was missing was placement and reach.
  - The app is now a **direct share target**: `res/xml/shortcuts.xml` declares a `<share-target>`,
    and a long-lived shortcut carrying the matching category is published at start-up. Android pairs
    the two by name and can then offer the app in the row of suggested targets at the top of the
    sheet, instead of only in the long alphabetical app list further down — where someone scanning
    for it reasonably concludes it is not installed.
  - The `ACTION_SEND` filter accepts `text/*` alongside `text/plain`. Senders do not all label shared
    text as `text/plain`, and the app was invisible to every one that does not. Measured on the
    emulator: for `text/html` the previous release does not resolve at all and this one does.
  - **What this cannot fix:** an app that draws its *own* in-app share sheet decides for itself which
    destinations it lists. TikTok does this; its sheet shows a fixed set plus a *More* entry, which
    opens the Android sheet where Flipper the Ripper appears. No change on this side can add an entry
    to another app's private sheet.

### Verified
- On the emulator: sharing a TikTok video link opens the system sheet with **Flipper the Ripper
  first in the list**; `dumpsys shortcut` shows the published shortcut as long-lived and carrying the
  share-target category. Not verified: placement in the suggested top row, which needs the chooser
  rather than the resolver and cannot be triggered from a shell.

## [1.8.1] - 2026-09-08

### Fixed
- **TikTok downloads work again.** Two faults, one behind the other.
  1. TikTok answers a mobile browser with a redirect to `snssdk1340://aweme/detail/…`, a deep link
     meant to hand the visit over to the native app. The hidden extractor WebView followed it, could
     not load that scheme, and landed on `chrome-error://chromewebdata` — the video page gone before
     the extraction script ran. The only symptom was a timeout that named no cause, reported as
     "Could not read this video". The WebView now refuses any navigation that is not http(s): it
     exists to read one page, and must never hand the visit to another app.
  2. With the page loading again, the scraped URL still failed to download: *Unable to resolve host
     "u002f"*. Media URLs are scraped from JSON embedded in HTML and arrive escaped; the scraper
     undid three escapes it had happened to meet (`&`, `%`, `\/`) and `/` was not among them, so the
     slashes survived as `\u002F` and the host parsed as the literal text "u002f". Any `\uXXXX` is
     decoded now, in the page script and again in Kotlin — hand-picking escapes was the bug, not the
     missing one.
  Verified end to end on the emulator: a public TikTok video downloads with its title, 497,598 bytes
  into Movies/FlipperTheRipper.

### Added
- **The extractor says what it saw when it fails.** A failed extraction used to leave nothing behind
  but a timeout. It now logs the page it ended up on, the title, whether the hydration blob was
  present and what status it carried — which is exactly what identified the deep-link redirect here.

## [1.8.0] - 2026-09-08

### Added
- **German translation.** The whole interface is translated (`values-de`), so a German phone shows
  German and everything else stays English. Two things deliberately remain English: the app's name
  and its tagline, which are the mark and appear that way in the banner and launcher, and "MIT
  License", which is the licence's own name. Documentation stays English by project convention.
- `TranslationTest` keeps the two string files in step. It catches the failures that are otherwise
  silent: a new English string with no German counterpart (one English sentence in the middle of a
  German screen), a dropped or renumbered format placeholder (a crash that only happens in German), a
  leftover entry for a string that no longer exists, and an entry that was copied across but never
  actually translated.

## [1.7.0] - 2026-09-08

### Changed
- **The engine update says what happened, in words.** Tapping *Update yt-dlp* used to answer with the
  library's enum name — "Engine updated: ALREADY_UP_TO_DATE" — which is a machine talking. The raw
  status is now parsed into a small `EngineUpdateOutcome` (updated / already current / unrecognised)
  and the wording lives in string resources: *"yt-dlp updated — broken downloads should work again"*
  or *"yt-dlp is already up to date"*. An unknown status is still shown rather than swallowed, so a
  future library version cannot quietly turn into a false success. Parsing tolerates case and
  whitespace, because this crosses a library boundary.
- **The update button now shows that it is working.** Fetching the engine is a network call that takes
  seconds; the button previously looked idle throughout, which invites a second tap. It now shows a
  progress indicator and "Updating…", and is disabled while the fetch runs.

### Fixed
- **A second tap could start a second engine update.** The guard against that sat *inside* the
  coroutine, so two taps in the same frame both passed it and both fetched. The flag is claimed
  synchronously now. Found by a test written for the new button state, not in the field.

### Added
- Unit tests for the update outcome and the button state (204 → 211), including a test that the
  wording and the self-healing retry rule agree on what counts as a real update — if they ever
  diverged, the app would claim it updated while refusing to retry on that basis.

### Added
- **README badges that are checked against reality.** Version, unit-test count, instrumented-test
  count and lines of code now sit at the top of the README, and `ReadmeBadgesTest` measures each one
  against the thing it claims to describe — the version against `build.gradle.kts`, the counts against
  the test sources, the line count against the Kotlin sources (with 10 % of headroom, because a badge
  that turns the build red for one added line would just get deleted). A PayPal donate button closes
  the badge block, pinned to the author's own account.
- **New unit tests (186 → 204).**
  - `VectorDrawableTest` guards the drawn marks as *files*: 108dp canvas, `evenOdd` plus at least two
    subpaths (without both, the glyph stops being a hole and becomes an overlay), everything inside
    the 33dp safe-zone radius, the punched glyph clear of the disc's lobe valleys, a themed-icon layer
    that is its own file in one flat colour, and in-app marks left black so the caller's tint decides.
    It measures with a small real path parser — a regex over the path string reads the deltas of `h`
    and the radii of `a` as absolute coordinates, which is exactly how an earlier check reported a
    glyph far outside a canvas it comfortably fits. The parser has its own test with hand-computed
    values for relative commands, arcs and `Z`.
  - `DesignTokensTest` keeps the spacing, shape and size scales ordered, holds real controls at or
    above the 48dp touch minimum, and pins the mark sizes in a sensible order.

### Changed
- **README screenshots are now device mockups** — one image showing Home, About, the clear-history
  confirmation and the light theme in phone frames, on a transparent background so it sits equally
  well in GitHub's light and dark themes.

## [1.6.0] - 2026-09-07

### Added
- **Clearing the download history now asks first.** The sweep button in the Downloads toolbar opens a
  confirmation dialog instead of wiping the list on the spot; nothing is removed until it is confirmed.
  The dialog names the count and, just as importantly, what is *not* lost — the downloaded videos stay
  on the device, only the list entries go. The question survives a rotation, so a config change can
  never silently drop it. Pinned by four instrumentation tests, including the singular wording ("the
  one entry", not "all 1 entries" — the body is a plural resource).

### Changed
- **New app icon and in-app marks.** The old shape motif — a soft blob that read as an apple — served
  as the hero, the empty state, the thumbnail placeholder and the platform badge all at once, and said
  nothing in any of those places. It is gone. In its place is one drawn mark: a soft eight-lobed
  Material *sunny* disc with a sharp download arrow and tray punched clean through it. Material 3
  Expressive asks for tension between rounded and angular forms, and here that contrast carries the
  meaning. Because the glyph is a hole rather than an overlay, the same path serves the coloured
  launcher icon, the themed-icon layer and every in-app use.
  - The launcher icon uses that mark, sized inside the 66 dp adaptive-icon safe zone (disc outer
    radius 32.7 of the permitted 33), with a **dedicated monochrome layer** — reusing the coloured
    foreground shipped its own tint into a layer the system is supposed to colour itself.
  - Empty history shows the mark as an outline: the same shape, still waiting to be filled.
  - A missing thumbnail shows the disc with a play triangle cut out.
  - The platform badge now uses a plain dot in the platform's colour: the lobed mark turns to mush
    below roughly 24 dp, and next to a word the badge only has to answer "which platform".
- The now-unused shape-morphing helpers (`MorphPolygonShape`, `MotionPolicy`) are removed.

### Fixed
- **An instrumentation test had been failing permanently.** `MediaStoreWriterInstrumentedTest` passed
  `Dispatchers.IO` to `runTest`, which kotlinx-coroutines-test rejects outright ("Dispatcher must
  implement TestDispatcher") — so the one test covering the real MediaStore write path never ran. It
  uses `runBlocking` now, which is the right tool for real file I/O anyway.

## [1.5.0] - 2026-09-07

### Added
- **About section** (Settings → About): app mark, version + build from `BuildConfig`, "Made by
  Martin Pfeffer", links to [celox.io](https://celox.io), the
  [source repository](https://github.com/pepperonas/flipper-the-ripper) and the MIT licence, and a
  **Support via PayPal** button (`martin.pfeffer@celox.io`, EUR, project name as note). Every fact is
  pinned in `AboutLinks` + a unit test so nothing about the author or licence can drift.
- **Screen transitions on Material 3 Expressive springs.** One central `ScreenTransitions`
  (`ui/motion`) builds every enter/exit from the theme's `MotionScheme`: lateral tab changes are a
  fade-through with a directional slide (6 % of the width, default spatial spring ζ 0.8 / k 380 for
  position + scale, effects springs for alpha — fast on the way out, default on the way in); the
  Instagram sign-in rises over Settings as a shared-axis-Y child and sinks back on pop. Reduced motion
  (system animator scale 0) collapses all of it to an instant cut. Measured on-device with a manual
  clock (`ScreenTransitionsTest`, 411 dp wide): the incoming tab starts 24.3 dp to the side, crosses
  in 11 frames (≈180 ms), overshoots by 0.4 dp for ≈110 ms and is at rest after ≈320 ms; under
  reduced motion it is at 0 on the first frame.

### Changed
- **Compact bottom navigation.** The classic 80 dp `NavigationBar` is replaced by M3 Expressive's
  `ShortNavigationBar`: **64 dp** (measured 168 px @ 420 dpi, was 210 px), every item still spans the
  full 64 dp — well above the 48 dp touch minimum; the active tab shows its filled icon. Pinned by
  `BottomBarHeightTest`, which measures both bars.
- **One shape, spacing and size scale.** Corner radii come from `MaterialTheme.shapes` (16 thumbnails ·
  20 fields/banners · 28 cards — the stray 24 dp cards joined the card tier), gaps from `Spacing`,
  control sizes from `Sizes` (`ui/theme/Dimens.kt`); no dp literal is left on the screens. The
  segmented toggle is a true pill (`CircleShape`). Hard-coded UI strings (hero tagline, history
  status labels) moved to resources.
- **System-bar icons follow the app's theme**, not the OS: with the app forced to Light on a dark
  phone the clock and status icons were white on the light surface.

## [1.4.0] - 2026-09-07

### Added
- **X (Twitter) support.** Public video posts on `x.com` and `twitter.com` (incl. `mobile.`/`www.`)
  download on-device through the bundled yt-dlp, which talks to X's guest-token GraphQL API — no
  sign-in, no browser check, no JS challenge; the video arrives as a plain progressive MP4. Verified
  under the exact on-device toolchain (PATH-stripped desktop yt-dlp, same arguments): a 19 s SpaceX
  clip, 2.7 MB. Posts marked sensitive, protected accounts and login-only content are not supported;
  live broadcasts resolve but are whole-stream recordings (a NASA launch stream ran past 1.2 GB).
- **Dailymotion support.** `dailymotion.com/video/…` and `dai.ly/…` links download on-device through
  yt-dlp (public metadata + HLS manifest, segments assembled by yt-dlp's native HLS downloader, the
  container fixed up by the bundled ffmpeg). Verified the same way: a 60 s 1080p clip, 31 MB.
- Both new platforms take part in the **self-healing stale-engine retry** introduced in 1.3.2: a
  non-terminal failure forces a yt-dlp update and retries once — the rule is now "every yt-dlp-routed
  platform" (`EngineRouting.usesYtDlp`) instead of "YouTube".

### Changed
- **Host detection matches registrable domains, not substrings.** Adding `x.com` exposed that the
  old `host.contains(…)` match would have claimed every host *ending* in it — `netflix.com`,
  `dropbox.com` — and already accepted look-alikes such as `youtube.com.evil.example`. A host now has
  to *be* the platform domain or a subdomain of it; genuine subdomains (`m.facebook.com`,
  `vm.tiktok.com`, `music.youtube.com`) keep working. Pinned by tests.
- The home screen hint and the share-target comment list all six platforms; each platform now maps to
  its *own* site as download `Referer` (the yt-dlp platforms never reach the CDN downloader, but a
  foreign site's Referer would be a 403 if one ever did).

## [1.3.2] - 2026-08-23

### Fixed
- **YouTube downloads work again.** Two independent breakages had accumulated:
  1. The bundled yt-dlp was frozen at its Sep-2024 release — YouTube refuses extractors that old
     ("Sign in to confirm you're not a bot" → every download failed with "The platform refused this
     request"). The engine library (youtubedl-android) is bumped 0.17.2 → **0.18.1**, which bundles
     **yt-dlp 2025.11.12** plus a **QuickJS** runtime for yt-dlp's JS challenges.
  2. The pinned `android_vr` player client is dead — YouTube extended PO-token enforcement to it, so
     its media fetches now abort with **HTTP 403 mid-stream** (reproduced on-device and with desktop
     yt-dlp 2026.08.19). The pin is removed: with QuickJS available, yt-dlp's **maintained default
     client rotation** is what keeps working, and the auto-updater keeps it current. Verified with a
     QuickJS-only runtime (extract → download → merge) and on-device.
- **A stale engine now heals itself mid-download.** If a YouTube download fails in a stale-looking
  way (bot-block, mid-stream 403, "no formats"), the app force-updates yt-dlp and retries once —
  but only when the update actually installed something new. This closes the fresh-install race
  where the first download starts before the background updater has replaced the bundled binary.
- **A shared link now always shows you the download.** Sharing a link into the app while it sat on
  the History/Settings tab started the download invisibly — you had to navigate around to find it.
  Navigation after a download starts now goes through an app-level navigator that works regardless
  of which tab is open: auto-download jumps to **History**, share-without-auto-download opens
  **Home** with the link prefilled.
- **A shared link can no longer enqueue the same download twice.** Two causes fixed: the incoming
  link bus replayed the last link to every new collector (an Activity/ViewModel recreation
  re-triggered the download), and a recreated Activity re-consumed its stale share intent. The bus
  is now consume-once, and only a fresh launch reads the launch intent.

### Added
- **Automatic update checks on every shared-in link** (throttled to 12 h), not just at app start —
  a warm process never re-runs the start-up check, which is exactly when most links arrive. This
  keeps yt-dlp fresh so platform breakage heals itself between app releases.
- **In-app update notice.** The app checks GitHub for a newer release (same 12 h throttle) and shows
  a dismissible card on Home linking to the release page. Dismissing a version keeps it hidden;
  anything newer re-appears.

## [1.3.1] - 2026-07-30

### Added
- **Share a downloaded video from History.** Every completed entry now has a **Share** button next to
  *Open* that opens the system share sheet with the saved file (works for any target — chats, mail, cloud,
  Quick Share). Because a finished download lands at the top of History, this also covers sharing straight
  after a download. The MediaStore `content://` URI is passed with a read grant; `file://` URIs are never
  shared (avoids FileUriExposedException).

## [1.3.0] - 2026-07-30

### Added
- **Facebook support.** Public Facebook videos (`facebook.com`, `fb.watch`, `fb.com`, incl. `m.`/`web.`)
  now download on-device. The extractor reads `browser_native_hd_url` from the video page's HTML — using
  a **desktop** user-agent, because Facebook's mobile page omits that field. Verified on device (5.9 MB).

### Changed
- **TikTok downloads are now reliable, not best-effort.** Instead of hoping to catch the `.mp4` off the
  wire, the extractor parses the page's `__UNIVERSAL_DATA_FOR_REHYDRATION__` JSON
  (`webapp.video-detail…video.playAddr`) and downloads it with a **`tiktok.com` Referer** plus the page's
  cookies — the old code sent an `instagram.com` Referer and missed the real CDN host
  (`v16-webapp-prime.tiktok.com`), which is why it kept failing. Verified on device (4.0 MB, real caption
  as the title).
- The download now sends the **site-matched Referer** per platform (instagram.com / tiktok.com /
  facebook.com); a mismatched Referer was itself a source of 403s.
- A failed non-Instagram extraction no longer shows the Instagram sign-in hint — the message is now
  platform-appropriate.

## [1.2.12] - 2026-07-29

### Changed
- **Hardened the Instagram fix with unit tests and a small refactor (no behaviour change).** The
  shortcode → media-id conversion moved out of in-page JavaScript into a pure, tested Kotlin object
  (`InstagramMediaId`, computed once and injected into the page script), and the CDN cookie-merge logic
  moved into a pure, tested `InstagramCookies`. New unit suites cover the media-id math (incl. values past
  64-bit range), the signed-in/signed-out cookie merge, and the emitted page script. Documentation
  (README) now describes the WebView + authenticated-media-API download path and the Instagram sign-in.

## [1.2.11] - 2026-07-29

### Fixed
- **Logged-in reels that kept returning 403 now resolve their URL through Instagram's own media API.**
  The earlier approach scraped the video URL out of the *embed* page, but for a signed-in/gated reel that
  URL is not authorized and the CDN rejects it (403) no matter what headers or cookies accompany it. The
  extractor now first calls Instagram's `/api/v1/media/<id>/info/` endpoint — a *same-origin* request from
  the page, so it carries the session, uses the browser's own TLS and returns the correctly authorized
  video URL. The media id is derived from the reel shortcode. When signed out the API returns HTML and the
  extractor falls back to the public embed scrape, so public reels are unchanged (verified). A failed
  download now also reports the HTTP status and host, to make any remaining edge case diagnosable.

## [1.2.10] - 2026-07-29

### Fixed
- **Logged-in Instagram downloads that still returned 403 now send the account's session.** Instagram's
  CDN gates some signed-in media on the `instagram.com` account cookies (`sessionid`, `ds_user_id`,
  `csrftoken`) — but those live on a different domain than the CDN host, so the browser's own cookie jar
  for the CDN never contains them. The download now lifts those session cookies into the request when
  signed in, and also sends the `Sec-Fetch-Dest/Mode/Site` and `Accept-Language` headers a real `<video>`
  element attaches. When signed out nothing changes, so public reels are unaffected (verified).

## [1.2.9] - 2026-07-29

### Fixed
- **Instagram downloads no longer fail with "error 403" after signing in.** The extractor captured
  Instagram's signed CDN video URL correctly, but the download then fetched it with a bare request that
  carried only a User-Agent. Instagram's CDN rejects that with 403 for a logged-in/gated clip — a real
  `<video>` element also sends an `instagram.com` Referer, a `Range` request and the session's CDN
  cookies. The download now sends exactly those headers, so gated videos transfer instead of 403-ing.
  Verified that public reels still download unchanged.

## [1.2.8] - 2026-07-29

### Fixed
- **"Sign in to Instagram" no longer shows a black screen.** The login WebView used the default Android
  WebView user-agent, which carries a `wv` token that identifies it as an embedded browser — Instagram
  detects that and serves it a blank page. The screen now presents the same real mobile-Chrome
  user-agent the media extractor already uses, so Instagram delivers its genuine login page. Verified:
  the full login form (username, password, *Log in*) now renders. A light backdrop and a loading spinner
  cover the moment before the page paints, and a render-process guard reloads instead of tearing the app
  down if the WebView renderer dies.

## [1.2.7] - 2026-07-29

### Added
- **Sign in to Instagram (Settings → Instagram) to download login-only reels.** Some reels are not
  publicly viewable — Instagram serves an empty/degraded page to anyone not signed in, so *no* method,
  on-device or server, can fetch them anonymously (yt-dlp itself says "empty media response … check if
  accessible without being logged-in"). Signing in with your account inside the app lets the hidden
  extractor WebView carry your session, so it can then read anything your account can see. The password
  is entered on Instagram's own page and never stored by the app — only the resulting session cookie is
  kept, exactly as a browser does. Sign out at any time.

### Fixed
- **Public Instagram reels now save with their real title** (e.g. `Video by elevopro.pets.mp4`) instead
  of `Untitled`. The video URL and title are read out of the embed's hydrated `shortcode_media` JSON
  (un-escaping the nested JSON first), rather than relying only on catching the video as it plays.
- **The failure message now tells you what to do.** A reel that isn't publicly available now says it
  needs a sign-in and points to Settings → Instagram, instead of a generic "could not read this video".

### Notes
- Whether a specific login-only reel downloads after signing in depends on your account being able to
  see it (a private account you don't follow still won't be accessible — that is Instagram's rule, not
  the app's).

## [1.2.6] - 2026-07-28

### Fixed
- **Wasted bands at the top and bottom of every screen, with content clipped.** The app nests a
  per-screen `Scaffold` (each with its own top app bar) inside the app-level `Scaffold` that owns the
  bottom navigation bar. The outer one already reserves the status-bar and navigation-bar insets, but
  those insets were never marked as consumed, so each inner Scaffold added them **again**: a thick empty
  strip above every title, and a dead strip above the menu bar that also cut off the last of the content
  (e.g. the Download-source section in Settings). Consuming the outer insets on the nav host
  (`consumeWindowInsets`) removes the double counting — titles now sit directly under the status bar and
  content runs down to the navigation bar.

## [1.2.5] - 2026-07-28

### Added
- **Instagram now downloads on the device — no server needed.** Instagram fingerprints the TLS
  handshake and hydrates the video URL with JavaScript, so no plain HTTP client (including the bundled
  yt-dlp, which lacks `curl_cffi`) can reach it — they all hit the login wall regardless of IP. The app
  now uses a hidden **WebView**, which is real Chromium: it presents a genuine Chrome fingerprint and
  runs the page's JS, reads the signed CDN URL, and downloads it with an ordinary client. Verified
  end-to-end on device: the reel that previously reported "login required" saves as a titled 5.5 MB
  file with a real preview.
- **Automatic per-platform routing with server fallback.** The app picks the engine per platform
  instead of a manual toggle: YouTube → on-device yt-dlp (the server's IP is blocked by YouTube),
  Instagram/TikTok → on-device WebView. A configured server is tried automatically as a fallback; if you
  explicitly pick the server in Settings it leads, with the on-device engine as fallback.

### Fixed
- **A previous "login required" message on Instagram was wrong** and is gone: the content was public
  all along; the on-device engine simply couldn't present a browser fingerprint. The WebView now
  downloads it directly.
- **TikTok no longer saves a broken file.** A too-loose match could grab a cover image (~13 KB) and
  report success; the app now requires a real `.mp4` above a plausible size and otherwise fails plainly,
  pointing to the server option. (TikTok remains hard everywhere — its IP block hits the server too.)

## [1.2.4] - 2026-07-28

### Changed
- **Only the download animates now.** The expressive shape motifs are static. They previously looped —
  in 1.2.3 while a download ran, before that permanently — which competed with the one signal that
  matters. The running download's progress indicator is the only continuous motion left. History cards
  also lost their staggered entrance, which in a lazy list re-fired every time a card scrolled back
  into view and read as flicker.
- **The history list jumps back to the top when a new download appears**, so a freshly pasted link is
  always the entry on screen instead of being added out of sight above the scroll position.

### Fixed
- **Saved videos show a real preview.** Several platforms (Instagram in particular) return no thumbnail
  URL, which left a placeholder on every card even though the video was on the device. A frame is now
  decoded from the saved file itself as a fallback.
- **Titles no longer carry the file extension** — `Video by kvashenaya.mp4` now reads
  `Video by kvashenaya`. The file on disk is unchanged.
- **Failed entries no longer show the raw source URL.** A card filled with
  `https://www.instagram.com/reel/DbDBPYJnUMW/?igsh=…` now shows just the identifying part,
  `reel/DbDBPYJnUMW`.
- **A misleading error message.** A refused request was reported as "this content is behind a login
  wall". That is frequently wrong: platforms answer the same way to a request they do not recognise as
  a real browser, even for fully public content — a reel reported this way downloads fine through the
  server backend, which can present a browser TLS fingerprint. The message now says what actually
  happened and points to Settings → Download source.

## [1.2.3] - 2026-07-28

### Fixed
- **The app could freeze immediately after opening.** Home peeks at the clipboard on first composition
  from a `LaunchedEffect`, whose coroutine runs on the **main** dispatcher — but the clipboard read was
  synchronous and used `ClipData.Item.coerceToText()`. For an item holding a URI that call opens the
  owning app's content provider and reads it, i.e. blocking cross-process I/O on the UI thread. With a
  `content://` item from a slow or unresponsive app on the clipboard (routine on devices with a
  clipboard manager or a phone-link feature), the UI froze on launch. The read now runs off the main
  thread and only ever looks at plain text and http(s) URIs — a video link can be nothing else, and
  neither requires touching another app's provider. An empty clipboard, as on a fresh emulator, never
  triggered this, which is why it only showed on real devices.

## [1.2.2] - 2026-07-27

### Fixed
- **The app animated continuously while idle.** The expressive shape motif looped unconditionally, so
  with nothing happening the Home hero, the empty history state and — once per list item — every
  placeholder thumbnail and platform badge kept animating forever. Looping motion is now tied to real
  work: it runs only while a download is queued or running, and settles into a resting shape when the
  last one finishes. One-shot effects (card entrance, screen transition, press feedback) are unchanged,
  since they end by themselves.
- **The two ways of reaching a tab behaved differently.** The automatic jump to History after starting a
  download called `navigate()` with no options at all, while the bottom bar used
  `popUpTo(start){saveState}` + `launchSingleTop` + `restoreState`. The same destination could therefore
  be pushed twice onto the back stack, and the jump bypassed tab state save/restore — letting a stale
  saved state later be restored over the live one. Both paths now go through one `navigateToTab()`.
- **Screen transitions overlapped and appeared to zoom.** Timings were asymmetric (400 ms in against
  200 ms out, with a scale only on the way back), so both screens were visible at once. Replaced with a
  proper Material 3 fade-through — the outgoing screen leaves in 90 ms and the incoming one follows —
  and reduced motion now disables the transition entirely.

### Added
- Project banner in the README.

## [1.2.1] - 2026-07-27

### Fixed
- **The app crashed immediately on every launch (release builds).** R8 renamed Apache Commons
  Compress's `ZipExtraField` implementations and made them abstract, because nothing constructs them
  directly — but its `ExtraFieldUtils` static initialiser instantiates them *reflectively*. The
  registry therefore threw `class ...zip.a is not a concrete class`, `YoutubeDL.init()` died with
  `ExceptionInInitializerError`, and the app was stuck in an unrecoverable launch-crash loop. Added the
  keep rules that keep those classes concrete, named and default-constructible. Debug builds were
  unaffected (no shrinking), which is why it only ever showed up in shipped APKs.
- **Downloads never finished, even when they visibly progressed.** The yt-dlp options ended with the
  `--` end-of-options guard, but youtubedl-android appends its *own* flags after ours — so
  `--ffmpeg-location <path>` was demoted to a positional argument and yt-dlp tried to download the
  ffmpeg binary path as a URL. Every run fetched the real video and then failed with
  `ERROR: [generic] '…/libffmpeg.so' is not a valid URL`, so nothing ever reached the gallery. The
  guard now sits only where the app owns argument order; the engine validates the URL scheme instead.
- **YouTube downloads failed at the media step.** The player-client list included clients that cannot
  work on a stock device: `ios`/`tv_simply` require a **GVS PO Token** ("…require a GVS PO Token which
  was not provided"), and the `web*` clients require the **n-signature JavaScript challenge** that
  Android has no runtime for. The app now uses `android_vr` — the one client needing neither — and the
  metadata and download steps use the *same* list, so a preview can no longer resolve via a client
  whose formats the download cannot fetch.
- **Filenames fell back to the raw URL.** A download started from a shared link carries the URL as its
  title until metadata resolves; if that lookup failed, files were saved as
  `https www.youtube.com watch v=….mp4`. yt-dlp already writes the real title into the file it
  produces, so that is now used before falling back to the URL, and the history entry is updated to
  match.
- Engine start-up can no longer take the app down: initialisation failures (including `Error`s) become
  a typed `EngineNotReady` result, a malformed server address is reported instead of thrown (it used to
  brick the app on launch with no way to reach Settings), and the application scope has an exception
  handler.

### Changed
- Backend: YouTube player clients aligned with the app's, `/api/resolve` now uses the same extractor
  args as the download, and the YouTube check is hostname-based rather than a substring match on the
  whole URL.

### Added
- Regression tests for all of the above (111 unit tests), including a guard asserting the R8 keep rules
  exist — the crash was a build-configuration bug that no ordinary unit test could have caught.

### Known limitations
- **Platform IP blocking is outside the app's control.** YouTube now answers the server backend's
  datacenter IP with "Sign in to confirm you're not a bot", and TikTok with "Your IP address is
  blocked" — for *any* player client, and a PO-token provider does not change it (it attests the
  stream, not the IP). **On-device downloads use your own connection and are therefore the more
  reliable route for YouTube.** Instagram serves an empty media response without login. Facebook works
  through the backend.

## [1.2.0] - 2026-07-26

### Added
- **Server backend (recommended) — reliably downloads YouTube & TikTok.** A new optional backend
  (`backend/`, FastAPI) runs the full yt-dlp toolchain on a server — a `deno` JS runtime for
  YouTube's n-sig challenge, `curl_cffi` impersonation for TikTok, ffmpeg for merging — then streams
  the finished file to the app. This solves the platform anti-bot walls that stock Android can't
  (v1.1.2's Known limitations). **Verified end-to-end:** YouTube downloads at full 4K quality and
  TikTok downloads via the deployed backend.
  - **Settings → Download source** toggles **On device** ⇄ **Server** (with URL + API-key fields).
    The app ships pointing at a server when one is baked in (git-ignored `backend.properties`),
    otherwise stays on-device; the user can configure a server at any time.
  - New `RemoteYtDlpEngine` (OkHttp) + `RoutingYtDlpEngine` pick the source live, so switching in
    Settings takes effect with no restart. Cancellation, progress and the typed error taxonomy are
    preserved across both paths.
  - Backend: `POST /api/resolve`, `POST /api/jobs`, `GET /api/jobs/{id}`, `GET /api/jobs/{id}/file`;
    `X-API-Key` auth; loopback + nginx TLS; nightly yt-dlp auto-update. Deploy docs in `backend/README.md`.

## [1.1.2] - 2026-07-26

### Fixed
- **Download engine could not start at all** — the single biggest bug. youtubedl-android ships its
  Python/ffmpeg payloads as `lib*.zip.so` files that it unzips from disk at runtime, but the app was
  built with the modern default `extractNativeLibs=false`, so those files were never written to disk
  and `YoutubeDL.init()` failed with "failed to initialize". Every download therefore failed
  immediately. Now `useLegacyPackaging = true` extracts them, so the engine initialises and
  extraction/metadata (title, thumbnail, duration) works again.
- **App icon** — the launcher glyph was off-centre. Redrawn as a centred download motif on an MD3
  violet gradient, sized to the adaptive-icon safe zone.

### Added
- **Automatic yt-dlp updates** — the bundled extractor is frozen at the library's release, so it is
  now refreshed on launch (throttled to once per 12 h) and the download worker updates + retries once
  when it hits a stale-extractor / merge failure.
- **Progressive (no-ffmpeg) fallback** — if a download fails needing an ffmpeg merge, it retries with
  a single pre-muxed format so it still produces a playable file.
- YouTube now uses the `android_vr` / `tv` player clients, which get past YouTube's `n`-signature
  JavaScript challenge (there is no JS runtime on Android).

### Known limitations
- **Platform anti-bot measures (2026).** Some platforms now gate the actual media download behind
  measures the bundled engine cannot fully satisfy on stock Android: **YouTube** may require a PO
  token / serves DRM on some clients, and **TikTok** may require TLS "impersonation" (`curl_cffi`).
  Extraction (title/thumbnail/formats) works, but such downloads can still fail until the upstream
  yt-dlp + dependencies close the gap. This affects all yt-dlp-based Android apps. Content not behind
  these walls downloads normally; keeping the engine updated (Settings → Update yt-dlp) helps.

## [1.1.1] - 2026-07-26

### Changed
- **APK size cut ~55%** (from ~74 MB to **~33 MB**) with **no loss of features or device support**:
  - **ABI splits** — ship one APK per architecture (`arm64-v8a`, `armeabi-v7a`) instead of a single
    fat APK carrying both architectures' native libs. Each user downloads only their architecture;
    both remain supported. Per-ABI `versionCode` offsetting keeps updates ordered.
  - **Dropped the bundled `aria2c`** downloader (~6 MB/ABI) — it was never invoked (yt-dlp handles
    downloading itself), so it was pure dead weight. No functional change.
- The release workflow now builds and publishes both per-ABI APKs.

## [1.1.0] - 2026-07-26

### Changed
- **Material 3 Expressive redesign.** The whole UI now uses the Expressive design system with a
  spring-based motion physics engine and playful shape motion:
  - `MaterialExpressiveTheme` + `MotionScheme.expressive()` — components animate with the theme's
    spatial/effects springs; `expressiveLightColorScheme()` + a tuned dark scheme; dynamic color kept.
  - Shape-morphing `MaterialShapes` motifs (continuous Cookie ↔ Clover morph) on the Home hero,
    engine banner, empty state and placeholder thumbnails, via `androidx.graphics:graphics-shapes`.
  - The expressive `LoadingIndicator` / `ContainedLoadingIndicator` replace plain spinners; running
    downloads show a `LinearWavyProgressIndicator`.
  - Emphasized typography roles for titles and section headers.
  - A custom spring-sliding **segmented toggle** for Video/Audio and theme (System/Light/Dark).
  - Staggered spring entrances for history cards, spring press-feedback on buttons, and expressive
    fade/scale transitions between destinations.
  - All motion is guarded by a reduced-motion check (system animation scale = 0).

### Added
- Choose Video or Audio for YouTube directly on Home via the segmented toggle (audio saved as `.m4a`).

### Notes
- Compose is now pinned explicitly (no BOM) to the `1.11` set with `material3:1.5.0-alpha`, which
  still targets `compileSdk 35` / AGP 8.7. Release-vital lint is skipped due to an AGP-8.7 lint bug
  against Compose 1.11 metadata; static analysis remains enforced by detekt + Spotless.

## [1.0.0] - 2026-07-26

### Added
- Download publicly accessible videos from **Instagram**, **YouTube** and **TikTok**
  via the bundled **yt-dlp** engine (youtubedl-android: yt-dlp + ffmpeg + aria2c).
- **Android share integration** — pick *Flipper the Ripper* from any app's share sheet
  (`ACTION_SEND`, `text/plain`); the link is imported and (optionally) downloaded automatically.
- **Clipboard detection** — on launch, a supported link on the clipboard is offered as a
  one-tap download.
- **Automatic flow** — analyse URL → detect platform → resolve metadata → download, with minimal taps.
- **Title-based filenames** — files are named after the video title with illegal filesystem
  characters replaced and UTF-8-safe truncation.
- **MediaStore output** — downloads land in the public *Movies* (or *Music* for audio) collection
  and appear immediately in the gallery, Google Photos and file managers. Scoped storage on
  Android 10+, legacy public-dir + media-scan on Android 7–9.
- **Background downloads** via WorkManager + a foreground (data-sync) service — survive
  screen-lock, app-minimise and rotation, with a live progress notification.
- **Material 3 UI** (Jetpack Compose): Home (URL, preview, title, platform, progress),
  History and Settings, with dynamic color and light/dark themes.
- **Typed error handling** — private video, login required, region blocked, rate-limited/stale,
  network, invalid link and cancellation are surfaced distinctly (ported from the
  inspector-rust stderr classifiers).
- **Engine update** — update the bundled yt-dlp at runtime to fix broken extractors.
- Signed release builds, GitHub Actions CI (build, lint, detekt, unit tests, coverage) and an
  automated tag-driven release workflow.

[Unreleased]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.8.1...HEAD
[1.8.1]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.8.0...v1.8.1
[1.8.0]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.7.0...v1.8.0
[1.7.0]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.6.0...v1.7.0
[1.6.0]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.5.0...v1.6.0
[1.5.0]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.4.0...v1.5.0
[1.4.0]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.3.2...v1.4.0
[1.3.2]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.3.1...v1.3.2
[1.3.1]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.3.0...v1.3.1
[1.3.0]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.2.7...v1.3.0
[1.2.7]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.2.6...v1.2.7
[1.2.6]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.2.5...v1.2.6
[1.2.5]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.2.4...v1.2.5
[1.2.4]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.2.3...v1.2.4
[1.2.3]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.2.2...v1.2.3
[1.2.2]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.2.1...v1.2.2
[1.2.1]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.2.0...v1.2.1
[1.2.0]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.1.2...v1.2.0
[1.1.2]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.1.1...v1.1.2
[1.1.1]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/pepperonas/flipper-the-ripper/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/pepperonas/flipper-the-ripper/releases/tag/v1.0.0
