Skip to content

Commit fd52857

Browse files
soloturnclaude
andcommitted
Add native Android app on the shared ripper engine
Separate Gradle build under android/. Root build never sees it. :core compiles the same Java sources as the desktop build (../../src/main/java) through a Sync-filtered copy at -release 17. 111 album rippers + 8 video rippers, shared not forked. :app is AGP 9.3.1 + Jetpack Compose. Three shims cover the only core->GUI coupling there is: MainWindow.addUrlToQueue, App.stringToAppendToFoldername, UpdateUtils.getThisJarVersion. Ripper discovery scans the classpath on desktop, which finds nothing in a DEX. A Gradle task regenerates the list from source each build. One patch to shared code: ripme.config.dir override in Utils.getConfigDir(). $HOME/.config is not writable on Android. UI is rip, queue, history, log, settings. Foreground service keeps rips alive in the background. Export copies a rip to Download/RipMe/ via MediaStore, since app-scoped storage dies on uninstall. Verified on emulator API 36.1: imgur rip downloaded, history row written, export landed in Download/RipMe/imgur_qbfcLyG. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TRPnr275L8rtX5pbNMjLUx
1 parent 582ad5e commit fd52857

39 files changed

Lines changed: 2923 additions & 0 deletions

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ java -Dripme.gui=compose -jar build/libs/ripme-<version>.jar
4444

4545
`./gradlew assemble` builds a self-contained jar (dependencies included) in `build/libs`; substitute the actual filename it produces, or run `./gradlew build` first to also run the tests.
4646

47+
## Android app (preview)
48+
49+
RipMe is also growing a native Android app (`android/`) around the same ripper engine, with a
50+
Compose Material3 UI covering rip / queue / history / log / settings, a foreground service so rips
51+
survive backgrounding, and a "copy to Downloads" export so a rip outlives uninstalling the app. It
52+
is a separate Gradle build the root build above never touches - see [`android/README.md`](android/README.md)
53+
for prerequisites, build/run instructions, and known gaps.
54+
4755
## Supported Sites
4856

4957
Jump to:

android/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
build/
2+
.gradle/
3+
local.properties

android/README.md

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
# RipMe for Android (preview)
2+
3+
A native Android app around the same ripper engine as the desktop build (~114 rippers minus
4+
`InstagramRipper` - see "What's shared" below), with a Compose Material3 UI: Rip / Queue / History
5+
/ Log / Settings, a foreground service so rips survive backgrounding, and a "copy to Downloads"
6+
export so a rip outlives uninstalling the app. This is a separate Gradle build under `android/` -
7+
the repo root build (`../build.gradle.kts`) never applies the Android Gradle Plugin and never
8+
includes this directory.
9+
10+
## Prerequisites
11+
12+
| Tool | Version used to build this | Notes |
13+
| --- | --- | --- |
14+
| JDK | 26 | Any JDK 26 works (e.g. Homebrew's `openjdk`); Gradle's daemon just needs to launch on it. |
15+
| Gradle | 9.7.0, the system `gradle` command | There is **no Gradle wrapper checked in under `android/`** - use whatever `gradle` resolves to on your `PATH`, or install 9.7.0 specifically if a different Gradle is your default. |
16+
| Android SDK | platforms 34 and 36, build-tools 36.x, licenses accepted | `sdkmanager --licenses`, then `sdkmanager "platforms;android-36" "build-tools;36.0.0"` (adjust the exact build-tools revision to whatever's current). |
17+
| AGP | 9.3.1 (pinned in `app/build.gradle.kts` / `core/build.gradle.kts`) | Applied per-module, not via a version catalog. |
18+
19+
**AGP 9 gotcha**: AGP 9 has built-in Kotlin support. Do not add `kotlin("android")` to either
20+
module - it hard-fails the build if present alongside `com.android.application`/`com.android.library`.
21+
Only the Compose compiler plugin (`org.jetbrains.kotlin.plugin.compose`) is applied separately.
22+
23+
**compileSdk is pinned at 36, not the newest available.** `android-37` is not published in any
24+
stable SDK channel; a dependency with `minCompileSdk=37` is simply unusable here. Likewise the
25+
AndroidX versions in `app/build.gradle.kts` (`core-ktx:1.18.0`, `compose-bom:2026.06.01`,
26+
`lifecycle:2.10.0`, …) are pinned to the newest releases that are still compatible with compileSdk
27+
36 - each was checked against its AAR's `aar-metadata.properties`. Do not bump any of these without
28+
re-checking that constraint; see that file's comments for the specifics.
29+
30+
### `local.properties`
31+
32+
Gitignored (machine-specific), and not created for you. Create `android/local.properties`:
33+
34+
```properties
35+
sdk.dir=/absolute/path/to/your/Android/sdk
36+
```
37+
38+
(`ANDROID_HOME` being set is not assumed - this file is the only thing that has to point at your
39+
SDK.)
40+
41+
## Build
42+
43+
```bash
44+
gradle -p android :app:assembleDebug
45+
```
46+
47+
APK lands at `android/app/build/outputs/apk/debug/app-debug.apk`. `gradle -p android :core:test`
48+
runs `RipperRegistryTest` (offline, deterministic - asserts the generated ripper registry resolves
49+
a handful of real URLs to the expected ripper classes, and that `InstagramRipper` is absent).
50+
51+
## Run
52+
53+
An emulator or a real device (`minSdk` 26 / Android 8.0+) both work; internet access is required
54+
(the rippers make real HTTP requests). To use the emulator from the command line rather than
55+
Android Studio:
56+
57+
```bash
58+
# Create once, if you don't already have a suitable AVD:
59+
# ~/Library/Android/sdk/cmdline-tools/latest/bin/avdmanager create avd \
60+
# -n Medium_Phone_API_36.1 -k "system-images;android-36;google_apis;arm64-v8a"
61+
~/Library/Android/sdk/emulator/emulator -avd Medium_Phone_API_36.1 -no-window -no-audio -no-boot-anim &
62+
63+
~/Library/Android/sdk/platform-tools/adb install -r android/app/build/outputs/apk/debug/app-debug.apk
64+
~/Library/Android/sdk/platform-tools/adb shell am start -n com.rarchives.ripme.android/.MainActivity
65+
```
66+
67+
(`adb`/`emulator` are not assumed to be on `PATH` - full paths above are what worked during
68+
development on macOS; adjust for your SDK location and OS.)
69+
70+
## Architecture
71+
72+
```
73+
android/
74+
settings.gradle.kts google() + mavenCentral(); include(":core", ":app")
75+
gradle.properties android.useAndroidX=true, org.gradle.jvmargs=-Xmx4g
76+
local.properties sdk.dir=… (gitignored, not checked in)
77+
core/ java-library: the shared engine, compiled at bytecode release 17
78+
app/ AGP application: Compose UI, bootstrap, foreground service
79+
```
80+
81+
### `:core` - what's shared with the desktop build, and what isn't
82+
83+
`:core` compiles the **same source files** as the desktop build
84+
(`src/main/java/com/rarchives/ripme/**`), via a filtered `Sync` task
85+
(`syncSharedJava` in `core/build.gradle.kts`) rather than a bare source-directory reference - see
86+
that task's comment for why a plain `srcDir` can't selectively exclude files without also deleting
87+
this module's own same-named shim classes. Excluded from the copy: `App.java` (Swing + CLI entry
88+
point), `ui/MainWindow.java` / `ui/UpdateUtils.java` / `ui/ClipboardUtils.java` and the Swing
89+
mouse-listener classes (all Swing-only), `uiUtils/**` (Swing), and `ripper/rippers/InstagramRipper.java`
90+
(depends on GraalVM's JS engine, which doesn't dex).
91+
92+
Three tiny shim classes live in `:core`'s own source (same package/class names as the files they
93+
replace) so the rest of the shared code compiles unchanged: `App.stringToAppendToFoldername`,
94+
`ui.MainWindow.addUrlToQueue` (repointed at a settable listener the Android app installs, see
95+
`RipMeApplication`), and `ui.UpdateUtils.getThisJarVersion` (returns the app's own version string,
96+
used to build `RedditRipper`'s User-Agent). Each has a header comment naming the desktop class it
97+
stands in for.
98+
99+
Ripper discovery is different by necessity: the desktop scans the runtime classpath/jar for
100+
`AbstractRipper` subclasses, which finds nothing inside a DEX (no classpath to enumerate). A
101+
generator Gradle task (`generateRipperRegistry`) lists the same ripper source directories at
102+
*build* time instead and writes `com.rarchives.ripme.android.RipperRegistry`, whose `getRipper(URL)`
103+
mirrors `AbstractRipper.getRipper`'s algorithm against that fixed, alphabetised list. It regenerates
104+
on every build, so it stays in sync as upstream adds rippers - nothing to maintain by hand.
105+
106+
One dependency-scope wrinkle worth knowing if you touch `:core`'s dependencies: `log4j-core` is
107+
shipped as `implementation` (not `compileOnly`, despite no Android code path ever calling
108+
`Utils.configureLogger()`, the only method that references it) because HotSpot's class verifier
109+
resolves those references just to *load* `Utils.class` - confirmed by a real `:core:test` failure
110+
when it was `compileOnly`. `:app` then excludes `log4j-core` again from its own dependency on
111+
`:core` and relies on ART's different (method-level, not whole-class) verifier being fine with that
112+
combination - confirmed on-device, not assumed; see `app/build.gradle.kts`'s comment for the full
113+
chain of evidence.
114+
115+
### The one shared-tree patch
116+
117+
`src/main/java/com/rarchives/ripme/utils/Utils.java`'s `getConfigDir()` honours a
118+
`ripme.config.dir` system property before falling through to the desktop's OS-specific paths (which
119+
resolve under `$HOME`, not writable on Android). This is the *only* change to shared source in this
120+
whole feature - `gradle build` at the repo root still passes unmodified, because nothing else under
121+
`src/` differs from what the desktop build already compiles.
122+
123+
### `:app`
124+
125+
Bootstrap (`RipMeApplication.onCreate`) sets that system property to `filesDir/config` before
126+
anything else touches `Utils` (whose static initialiser loads `rip.properties`), then points
127+
`rips.directory` at `getExternalFilesDir(DIRECTORY_DOWNLOADS)/rips` (app-scoped external storage -
128+
no `WRITE_EXTERNAL_STORAGE` permission needed, but wiped on uninstall, which is what the export
129+
feature below is for), and forces `urls_only.save=false` / `play.sound=false` off since their
130+
underlying desktop code paths (`java.awt.Desktop`, `javax.sound.sampled`) don't exist on Android.
131+
132+
The engine itself (`engine/RipController.kt`, `engine/queue/QueueController.kt`,
133+
`engine/history/HistoryStore.kt`) is a process-level `RipEngine` singleton exposing `StateFlow`s,
134+
ported from the desktop's Compose-GUI controllers of the same names
135+
(`src/main/kotlin/com/rarchives/ripme/ui/compose/**`) with `Compose State`/`SnapshotStateList`
136+
swapped for `StateFlow` so `RipService` (a plain, non-`@Composable` foreground service) can observe
137+
rip progress without depending on the Compose runtime. `RipService` starts when the queue starts
138+
draining and stops itself (debounced) once it observes both `busy=false` and an empty queue - see
139+
that file's header comment for why the stop decision specifically needs debouncing.
140+
141+
The five `NavigationBar` destinations (`ui/rip`, `ui/queue`, `ui/history`, `ui/log`,
142+
`ui/settings`) are the phone equivalent of the desktop's `MainScreen` + togglable side panels -
143+
Rip is a destination of equal standing rather than the permanently-visible base layout, since a
144+
phone doesn't have the width for both at once. Behaviour is preserved 1:1 from each desktop
145+
screen's port (add/remove/clear queue, history list/re-rip/clear, coloured log lines, the same
146+
config keys); layout is redrawn for a single narrow column instead of desktop's two-column grids
147+
and fixed-width table.
148+
149+
### Export to Downloads
150+
151+
`export/MediaStoreExporter.kt` copies a finished rip's files into `MediaStore.Downloads`, under
152+
`Download/RipMe/<album folder name>` - reusing the ripper's own (already filesystem-safe) folder
153+
name rather than re-sanitising a title. Surfaced both on `HistoryScreen`'s per-row action and on
154+
`RipScreen`'s "rip complete" card. Requires Android 10 (API 29) for the `MediaStore.Downloads`
155+
collection this uses; on API 26-28 the action reports that plainly rather than attempting a legacy
156+
`WRITE_EXTERNAL_STORAGE` write (out of scope for this preview - see Known Gaps).
157+
158+
## Known Gaps
159+
160+
- **No `InstagramRipper`.** Its dependency on GraalVM's JS engine doesn't dex; excluded from both
161+
`:core`'s synced source and its generated ripper registry.
162+
- **No in-app updates.** `UpdateUtils.getThisJarVersion()` is stubbed to return the app's own
163+
version (for `RedditRipper`'s User-Agent); the desktop's actual update-check/self-replace flow
164+
has no Android equivalent, and Settings has no update-check control.
165+
- **No SAF folder picker.** The rips directory is fixed at `getExternalFilesDir(DIRECTORY_DOWNLOADS)/rips`
166+
and shown read-only in Settings; letting a user redirect it to arbitrary shared storage would need
167+
the core's file IO abstracted behind an interface first (`AbstractRipper` uses `java.nio.file`
168+
directly throughout).
169+
- **No R8/minification.** `isMinifyEnabled = false`. The generated `RipperRegistry` resolves rippers
170+
via plain reflection (`Class.forName` + a `URL`-arg constructor) - safe only because nothing
171+
renames or strips those classes today. Turning on minification needs keep rules for
172+
`com.rarchives.ripme.ripper.rippers.**` first.
173+
- **Settings covers only the phone-relevant config keys**: threads, timeout, retries, overwrite,
174+
album titles, save order, save descriptions, plus a read-only rips-directory display. Dropped
175+
versus the desktop's config screen: log level (no `log4j-core` in the APK - see `:core`'s
176+
dependency-scope note above; ART tolerates its *absence* at runtime, but there's still no UI hook
177+
for a level nothing consumes), sound (`play.sound` is forced off at bootstrap), clipboard-autorip
178+
(`ClipboardUtils` is Swing/AWT-only, excluded from `:core`'s synced source), language, SSL/window/
179+
URL-history toggles, cookies configuration, and the URL-list file import (needs a picker - see the
180+
SAF gap above).
181+
- **`HistoryEntry.dir` doesn't survive an app restart.** This is a gap in the *shared* Java
182+
`HistoryEntry`/`History` classes (`src/main/java/com/rarchives/ripme/ui/`), not Android-specific:
183+
`HistoryEntry.toJSON()` never writes a `"dir"` key, even though `fromJSON()` reads one if present.
184+
A history row created earlier in the same app process has its folder path and offers "Export to
185+
Downloads"; the same row reloaded from `history.json` after the app was killed and relaunched has
186+
lost track of its folder and shows "Folder unknown for this entry" instead (re-ripping the same
187+
URL repopulates it). Not fixed here since it would mean touching shared source beyond the one
188+
sanctioned `Utils.getConfigDir()` patch.
189+
- **e621 currently scrapes zero images** (a site/selector mismatch in `E621Ripper` - it resolves
190+
the URL, paginates and completes normally, just downloads nothing). This affects the desktop
191+
build identically; it's not an Android-specific regression, just something you'll notice if you
192+
use e621 as a smoke-test URL.
193+
194+
## Testing
195+
196+
- `gradle -p android :core:test` - offline, deterministic, no emulator needed.
197+
- `gradle -p android :app:assembleDebug` - compiles the whole app; review any new D8 "missing
198+
class" warnings against the `-dontwarn` list already in `app/proguard-rules.pro` (desktop-only
199+
APIs the shared engine references but never calls on Android: `java.awt.**`, `javax.swing.**`,
200+
`javax.sound.**`, `org.apache.logging.log4j.core.**`).
201+
- `gradle build` at the **repo root** still passes unmodified - the only shared-tree change is the
202+
`Utils.getConfigDir()` patch, and this `android/` build is entirely separate from the root one.

android/app/build.gradle.kts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// :app is the actual Android application: Compose UI + bootstrap + foreground service, on top of
2+
// the shared ripper engine from :core. AGP 9 has built-in Kotlin support - applying
3+
// `kotlin("android")` here as well hard-fails the build (see android/core's neighbour comment in
4+
// the plan this module was built from), so the only plugins are the Android application plugin
5+
// itself and the Compose compiler plugin.
6+
plugins {
7+
id("com.android.application") version "9.3.1"
8+
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10"
9+
}
10+
11+
android {
12+
namespace = "com.rarchives.ripme.android"
13+
compileSdk = 36
14+
15+
defaultConfig {
16+
applicationId = "com.rarchives.ripme.android"
17+
minSdk = 26 // java.nio.file (used throughout the shared ripper engine) isn't desugared below 26
18+
targetSdk = 36
19+
versionCode = 1
20+
// Tracks the root build's `version` (../../build.gradle.kts) by hand - the two builds are
21+
// deliberately independent (android/ is a separate Gradle build the root never sees, see
22+
// settings.gradle.kts), so there's no automated link between them. Bump this alongside that
23+
// one. Fed to UpdateUtils.setThisJarVersion via BuildConfig.VERSION_NAME (see
24+
// RipMeApplication) so RedditRipper's User-Agent (RedditRipper.java:71) carries a real version.
25+
versionName = "1.7.94"
26+
}
27+
28+
buildFeatures {
29+
compose = true
30+
buildConfig = true // AGP 9 no longer generates BuildConfig by default; RipMeApplication reads BuildConfig.VERSION_NAME
31+
}
32+
33+
compileOptions {
34+
sourceCompatibility = JavaVersion.VERSION_17
35+
targetCompatibility = JavaVersion.VERSION_17
36+
}
37+
38+
buildTypes {
39+
release {
40+
// The ripper registry (:core's generated RipperRegistry) resolves rippers via plain
41+
// Class.forName + reflective construction - safe today only because nothing shrinks or
42+
// renames those classes. Minification is out of scope for v1 (see the plan's "out of
43+
// scope" section); revisit with R8 keep-rules for com.rarchives.ripme.ripper.rippers.**
44+
// before ever shipping a minified build.
45+
isMinifyEnabled = false
46+
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
47+
}
48+
}
49+
50+
packaging {
51+
// Several dependencies shared with :core (jsoup/commons-* and friends) ship the same
52+
// META-INF license/notice files; the default AGP behavior of erroring on any duplicate
53+
// META-INF resource would otherwise fail packaging. Desktop's shadow-jar merge handles the
54+
// same fan-in via mergeServiceFiles(); here we just drop the duplicates.
55+
resources.excludes += setOf(
56+
"META-INF/LICENSE*",
57+
"META-INF/NOTICE*",
58+
"META-INF/DEPENDENCIES",
59+
"META-INF/*.kotlin_module",
60+
)
61+
}
62+
}
63+
64+
dependencies {
65+
// :core ships log4j-core as `implementation` (see android/core/build.gradle.kts) because
66+
// HotSpot's *whole-class* verifier resolves Utils.configureLogger()'s log4j-core references
67+
// merely to LOAD Utils.class, even though no Android code path calls configureLogger(). ART
68+
// verifies method-by-method instead and soft-fails an individual method when a class it alone
69+
// references is missing (see https://source.android.com/docs/core/runtime/verifier) - so :app
70+
// excludes log4j-core again here and relies on that difference. CONFIRMED on-device with
71+
// log4j-core absent from the APK (Phase B emulator run, Medium_Phone_API_36.1): log4j-api's
72+
// own "could not find a logging provider" fallback prints cleanly to logcat (no NoClassDefFoundError
73+
// loading Utils.class), and RipperRegistry.getRipper reflectively constructed ~119 ripper
74+
// classes end to end - including E621Ripper, whose AbstractRipper superclass field
75+
// initializer (`Utils.getURLHistoryFile()`) forces Utils.class to load+verify on essentially
76+
// every ripper instantiation attempt - with no VerifyError/NoClassDefFoundError, and that
77+
// ripper then ran a real rip against e621.net to RIP_COMPLETE. The matching `-dontwarn` lives
78+
// in proguard-rules.pro (dormant today - D8 alone, without minification, doesn't do the
79+
// "missing class" analysis that warning would silence; see that file's header comment).
80+
implementation(project(":core")) {
81+
exclude(group = "org.apache.logging.log4j", module = "log4j-core")
82+
}
83+
84+
// AndroidX versions below are pinned exactly to the plan's verified-against-compileSdk-36
85+
// table - do not bump. Anything newer (core-ktx 1.19.0, compose-bom 2026.08.00, lifecycle
86+
// 2.11.0 at time of writing) demands compileSdk 37, which isn't published in any stable SDK
87+
// channel on this machine and fails AGP's AAR-metadata check.
88+
implementation("androidx.core:core-ktx:1.18.0")
89+
implementation("androidx.activity:activity-compose:1.13.0")
90+
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
91+
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
92+
93+
implementation(platform("androidx.compose:compose-bom:2026.06.01"))
94+
implementation("androidx.compose.ui:ui")
95+
implementation("androidx.compose.material3:material3")
96+
// material-icons-core (curated, BOM-pinned), not -extended: the extended set is a
97+
// multi-thousand-icon, multi-megabyte artifact that exists specifically so apps that don't
98+
// need it can skip it. Five NavigationBar glyphs + three action-button glyphs comfortably fit
99+
// in core - verified directly against the resolved AAR's classes.jar (not assumed: the first
100+
// pass here reached for Icons.Filled.History and it doesn't exist in this artifact - see
101+
// ui/nav/Destination.kt's DateRange substitution).
102+
implementation("androidx.compose.material:material-icons-core")
103+
}

0 commit comments

Comments
 (0)