|
| 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. |
0 commit comments