Просмотр исходного кода

document Android, AIM Express, Mandarin client setup

Mike 1 неделя назад
Родитель
Сommit
78552acbad
6 измененных файлов с 739 добавлено и 210 удалено
  1. 0 14
      Makefile
  2. 181 0
      docs/CLIENT_AIM_EXPRESS.md
  3. 210 0
      docs/CLIENT_ANDROID.md
  4. 23 15
      docs/CLIENT_IOS.md
  5. 325 0
      docs/CLIENT_MANDARIN.md
  6. 0 181
      scripts/fetch_aimex.sh

+ 0 - 14
Makefile

@@ -168,17 +168,3 @@ macos-trust-ca: ## Trust $(CA_CERT) as a root in the macOS system keychain, repl
 		echo "removed previous $(CA_CERT_CN)"; \
 	done
 	sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain $(CA_CERT)
-
-################################################################################
-# Web Clients
-################################################################################
-
-# Seconds between Wayback Machine requests. The archive throttles by refusing
-# connections outright, so raise this rather than retrying harder if a run
-# starts stalling.
-WAYBACK_DELAY ?= 2.5
-
-.PHONY: fetch-aim-express
-fetch-aim-express: ## Mirror the archived AIM Express web clients from the Wayback Machine into $(CLIENT_DIR)
-	mkdir -p $(CLIENT_DIR)
-	./scripts/fetch_aimex.sh -o $(CLIENT_DIR) -d $(WAYBACK_DELAY)

+ 181 - 0
docs/CLIENT_AIM_EXPRESS.md

@@ -0,0 +1,181 @@
+# AIM Express Client Setup
+
+This guide explains how to run AIM Express, AOL's Flash-based web AIM client, and connect it to Open OSCAR Server.
+Open OSCAR Server's nginx container serves the client's web assets, which you download from the Internet Archive.
+The client sends its API requests to hardcoded AOL hostnames, so setup also consists of redirecting those hostnames to
+your server and trusting your server's root CA certificate in the browser.
+
+The examples in this guide assume your server's hostname is `ras.dev`, the hostname passed to `make docker-run`. If you
+use a different hostname, substitute it wherever `ras.dev` appears.
+
+<p align="center">
+   <img width="551" alt="AIM Express Screenshot" src="https://github.com/user-attachments/assets/2a0b3c96-4597-4995-a832-f0342338466a" />
+</p>
+
+> [!NOTE]
+> Open OSCAR Server is not affiliated with or endorsed by AOL or Yahoo. AIM and AIM Express are trademarks of their
+> respective owners. This project does not distribute the AIM Express client; you are responsible for ensuring that
+> obtaining and using it complies with the laws that apply to you.
+
+## Prerequisites
+
+1. **Flash-Enabled Web Browser**
+
+   Any browser that can run the Adobe Flash Player plugin works. On Windows XP, for example,
+   [Mypal](https://www.mypal-browser.org/) supports Flash.
+
+   The [Ruffle](https://ruffle.rs/) Flash emulator does not yet run the client completely.
+
+2. **SSL-Enabled Open OSCAR Server Instance**
+
+   Configure and run an SSL-enabled Open OSCAR Server instance using the [Docker Setup Guide](./DOCKER.md). The Web API
+   must be enabled (`WEBAPI_ENABLED=true`, the default).
+
+## Setup Procedure
+
+1. **Download the Web Assets**
+
+   Download the following files from the [Wayback Machine](https://web.archive.org/). Make sure to download the
+   original files rather than the archive's viewer pages, especially for the `.swf` files.
+
+   | CDN URL                                                                                                              |
+   |----------------------------------------------------------------------------------------------------------------------|
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/AC_OETags.js`                                     |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/ConversationAssets.swf`                           |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/OnlinePanel.swf`                                  |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/WidgetMain.html`                                  |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/WidgetMain.swf`                                   |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/_uac/adpage.html`                                 |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/aim-express/babybunny.png`               |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/aim-express/default_buddy_icon.png`      |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/aim-express/default_imserv_icon.png`     |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/aim-express/default_login_user_icon.png` |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/aim-express/progress_animation.swf`      |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/check.png`              |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/facebook_h.png`         |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/facebook_n.png`         |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/ls_h.png`               |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/ls_n.png`               |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/plus.png`               |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/twitter_h.png`          |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/twitter_n.png`          |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/youtube_h.png`          |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/youtube_n.png`          |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/all.gif`              |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/facebook.gif`         |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/lifestream.gif`       |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/location.gif`         |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/myspace.gif`          |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/photo.gif`            |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/status.gif`           |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/twitter.gif`          |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/video.gif`            |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/help/aim_or_fb.html`              |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/serviceicons/runningman.png`      |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/emoticons/GromitEmoticons.swf`           |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/norris.txt`                                       |
+   | `http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/portal.php`                                       |
+
+2. **Place the Web Assets**
+
+   Save each file under `clients/express/` in your Open OSCAR Server directory, keeping the part of its path after
+   `100820.5475.1.en-us/`. nginx serves this directory at `http://ras.dev/client/express/`.
+
+   The finished file tree should look like this:
+
+   ```
+   clients/express/
+   ├── AC_OETags.js
+   ├── ConversationAssets.swf
+   ├── OnlinePanel.swf
+   ├── WidgetMain.html
+   ├── WidgetMain.swf
+   ├── _uac
+   │   └── adpage.html
+   ├── loadable
+   │   ├── aim-express
+   │   │   ├── babybunny.png
+   │   │   ├── default_buddy_icon.png
+   │   │   ├── default_imserv_icon.png
+   │   │   ├── default_login_user_icon.png
+   │   │   └── progress_animation.swf
+   │   ├── common
+   │   │   ├── chicklets
+   │   │   │   ├── check.png
+   │   │   │   ├── facebook_h.png
+   │   │   │   ├── facebook_n.png
+   │   │   │   ├── ls_h.png
+   │   │   │   ├── ls_n.png
+   │   │   │   ├── plus.png
+   │   │   │   ├── twitter_h.png
+   │   │   │   ├── twitter_n.png
+   │   │   │   ├── youtube_h.png
+   │   │   │   └── youtube_n.png
+   │   │   ├── filterTypes
+   │   │   │   ├── all.gif
+   │   │   │   ├── facebook.gif
+   │   │   │   ├── lifestream.gif
+   │   │   │   ├── location.gif
+   │   │   │   ├── myspace.gif
+   │   │   │   ├── photo.gif
+   │   │   │   ├── status.gif
+   │   │   │   ├── twitter.gif
+   │   │   │   └── video.gif
+   │   │   ├── help
+   │   │   │   └── aim_or_fb.html
+   │   │   └── serviceicons
+   │   │       └── runningman.png
+   │   └── emoticons
+   │       └── GromitEmoticons.swf
+   ├── norris.txt
+   └── portal.php
+   ```
+
+3. **Redirect the Hostnames**
+
+   On the machine running the browser, add the following entries to the hosts file. Replace `127.0.0.1` with the
+   server's IP address if the server runs on a different machine.
+
+   ```
+   127.0.0.1  ras.dev
+   127.0.0.1  api.oscar.aol.com
+   127.0.0.1  api.screenname.aol.com
+   127.0.0.1  login.oscar.aol.com
+   127.0.0.1  my.screenname.aol.com
+   ```
+
+   The hosts file is `/etc/hosts` on macOS and Linux, and `C:\Windows\System32\drivers\etc\hosts` on Windows. Editing
+   it requires administrator privileges. Skip the `ras.dev` entry if that hostname already resolves through DNS.
+
+   Alternatively, you can redirect these hostnames for your whole network with DNS poisoning: configure a local DNS
+   server you control, such as dnsmasq or Pi-hole, to answer for them with your server's IP address.
+
+4. **Trust the Root CA Certificate**
+
+   Open `http://ras.dev/certs/ca.crt` in the browser and trust the certificate to identify websites when prompted.
+   Firefox-based browsers such as Mypal keep their own certificate store. Other browsers use the operating system's
+   store, so on those, import `ca.crt` into the OS's trusted root certificates instead.
+
+5. **Launch the Client**
+
+   Open `http://ras.dev/client/express/WidgetMain.html` in the browser and sign in with your Open OSCAR Server screen
+   name and password.
+
+## Troubleshooting
+
+**The client can't connect**
+
+Open `https://api.screenname.aol.com/` in the same browser. If successful, you should see the following payload:
+
+```json
+{"response":{"statusCode":200,"statusText":"Ok","data":{}}}
+```
+
+If the page fails to load, the browser can't reach the server: confirm the server is running and check the hosts file
+entries from step 3. If the browser shows a certificate warning, repeat step 4.
+
+**The client page is blank or shows a Flash install prompt**
+
+Confirm that the Flash plugin is installed and enabled for the site, and that the files in `clients/express/` match the
+tree in step 2. Each `.swf` file should start with the bytes `CWS` or `FWS`. A file that starts with `<!DOCTYPE` or
+`<html>` is a Wayback Machine viewer page rather than the original file; download the original file again.

+ 210 - 0
docs/CLIENT_ANDROID.md

@@ -0,0 +1,210 @@
+# AIM for Android Client Setup
+
+This guide explains how to run AIM for Android 1.10.9.1 in an Android 2.3.3 emulator and connect it to Open OSCAR
+Server. The app talks to hardcoded AOL hostnames, so setup consists of redirecting those hostnames in the emulator and
+installing your server's root CA certificate.
+
+The examples in this guide assume your server's hostname is `ras.dev`, the hostname passed to `make docker-run`. If you
+use a different hostname, substitute it wherever `ras.dev` appears.
+
+<p align="center">
+   <img width="350" alt="Android Screenshot" src="https://github.com/user-attachments/assets/359919aa-be84-4b3e-a3f9-9886686db47b" />
+</p>
+
+> [!NOTE]
+> Open OSCAR Server is not affiliated with or endorsed by AOL or Yahoo. AIM and AIM for Android are trademarks of their
+> respective owners. This project does not distribute the AIM for Android app; you are responsible for ensuring that
+> obtaining and using it complies with the laws that apply to you.
+
+## Prerequisites
+
+1. **x86_64 Host**
+
+   An Intel Mac, x86_64 Linux with KVM, or Windows with *Windows Hypervisor Platform* enabled. On Windows, run the
+   commands in PowerShell, not WSL. Apple Silicon and other ARM hosts are not supported.
+
+2. **JDK 17 or Later**
+
+   The Android SDK tools require JDK 17 or later.
+
+   **Linux:**
+
+   On Ubuntu, run the following. On other distributions, install the equivalent package.
+
+   ```shell
+   sudo apt install openjdk-17-jdk-headless
+   ```
+
+   **macOS:**
+
+   ```shell
+   brew install --cask temurin@17
+   ```
+
+   **Windows (PowerShell):**
+
+   ```powershell
+   winget install EclipseAdoptium.Temurin.17.JDK
+   ```
+
+   Confirm that `java -version` reports version 17 or later. If your shell sets `JAVA_HOME`, point it at JDK 17 or
+   later.
+
+3. **SSL-Enabled Open OSCAR Server Instance**
+
+   Configure and run an SSL-enabled Open OSCAR Server instance using the [Docker Setup Guide](./DOCKER.md). The Web API
+   must be enabled (`WEBAPI_ENABLED=true`, the default).
+
+## Setup Procedure
+
+1. **Acquire the AIM APK**
+
+   Download AIM 1.10.9.1 by searching for `AIM 1.10.9.1 APK`.
+
+2. **Set Up the SDK Environment**
+
+   **Linux:**
+
+   ```shell
+   export ANDROID_HOME="$HOME/Android/Sdk"
+   export PATH="$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
+   ```
+
+   ```shell
+   curl -O https://dl.google.com/android/repository/commandlinetools-linux-11479570_latest.zip
+   mkdir -p "$ANDROID_HOME"
+   unzip commandlinetools-linux-11479570_latest.zip -d "$ANDROID_HOME/cmdline-tools"
+   mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
+   ```
+
+   **macOS:**
+
+   ```shell
+   brew install --cask android-commandlinetools
+   ```
+
+   ```shell
+   export ANDROID_HOME="$(brew --prefix)/share/android-commandlinetools"
+   export PATH="$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
+   ```
+
+   **Windows (PowerShell):**
+
+   ```powershell
+   $env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk"
+   $env:Path = "$env:ANDROID_HOME\emulator;$env:ANDROID_HOME\platform-tools;$env:ANDROID_HOME\cmdline-tools\latest\bin;$env:Path"
+   ```
+
+   ```powershell
+   curl.exe -O https://dl.google.com/android/repository/commandlinetools-win-11479570_latest.zip
+   Expand-Archive commandlinetools-win-11479570_latest.zip "$env:ANDROID_HOME\cmdline-tools"
+   Move-Item "$env:ANDROID_HOME\cmdline-tools\cmdline-tools" "$env:ANDROID_HOME\cmdline-tools\latest"
+   ```
+
+3. **Create the Emulator**
+
+   ```shell
+   sdkmanager "platform-tools" "emulator" "platforms;android-10" "system-images;android-10;default;x86"
+   avdmanager create avd -n aim_api10 -k "system-images;android-10;default;x86" -d "3.2in HVGA slider (ADP1)"
+   ```
+
+   `sdkmanager` prompts you to accept each SDK license; enter `y` to continue. `avdmanager` may print
+   `Could not load devices from ... devices.xml` errors. These are harmless.
+
+   Android 2.3.3 (API 10) is the oldest x86 system image still available, and it was current when this version of AIM
+   was released in 2011. The ADP1 device profile matches the small 320x480 screen the app was designed for.
+
+4. **Start the Emulator**
+
+   ```shell
+   emulator -avd aim_api10 -writable-system -no-snapshot-save -gpu swiftshader_indirect -no-boot-anim
+   ```
+
+   Always start the emulator with `-writable-system`. Without it, the changes made in the next two steps are discarded.
+   If the emulator fails to boot, replace `-gpu swiftshader_indirect` with `-gpu off`.
+
+5. **Redirect the AOL Hostnames**
+
+   In a second terminal, add the AOL hostnames to the emulator's hosts file. `10.0.2.2` is the emulator's address for
+   the host machine. If the server runs on a different machine, use that machine's IP address instead.
+
+   **macOS / Linux:**
+
+   ```shell
+   adb root
+   adb remount
+   adb pull /system/etc/hosts .
+   cat >> hosts <<'EOF'
+   10.0.2.2  api.oscar.aol.com
+   10.0.2.2  api.screenname.aol.com
+   10.0.2.2  login.oscar.aol.com
+   10.0.2.2  my.screenname.aol.com
+   EOF
+   adb push hosts /system/etc/hosts
+   ```
+
+   **Windows (PowerShell):**
+
+   ```powershell
+   adb root
+   adb remount
+   adb pull /system/etc/hosts .
+   $entries = "`n10.0.2.2  api.oscar.aol.com`n10.0.2.2  api.screenname.aol.com`n10.0.2.2  login.oscar.aol.com`n10.0.2.2  my.screenname.aol.com`n"
+   [IO.File]::AppendAllText("$PWD\hosts", $entries)
+   adb push hosts /system/etc/hosts
+   ```
+
+   Verify with `adb shell ping -c1 api.oscar.aol.com`.
+
+   Alternatively, you can redirect these hostnames for your whole network with DNS poisoning: configure a local DNS
+   server you control, such as dnsmasq or Pi-hole, to answer for them with your server's IP address. The emulator uses
+   the host machine's DNS settings, so this works as long as the host uses that DNS server.
+
+6. **Install the Root CA Certificate**
+
+   On the host machine, download your server's root CA certificate from `http://ras.dev/certs/ca.crt`, or copy it from
+   `certs/ca.crt` in your Open OSCAR Server directory. The download requires the host machine to resolve `ras.dev`. On
+   Windows, use `curl.exe` instead of `curl` in this step.
+
+   ```shell
+   curl -O http://ras.dev/certs/ca.crt
+   ```
+
+   Then run the following commands from the directory containing `ca.crt`.
+
+   ```shell
+   curl -O https://repo1.maven.org/maven2/org/bouncycastle/bcprov-jdk18on/1.78/bcprov-jdk18on-1.78.jar
+   adb pull /system/etc/security/cacerts.bks .
+   keytool -J-Dorg.bouncycastle.bks.enable_v1=true -importcert -v -trustcacerts -noprompt -alias openoscarca -file ca.crt -keystore cacerts.bks -storetype BKS-V1 -providerclass org.bouncycastle.jce.provider.BouncyCastleProvider -providerpath bcprov-jdk18on-1.78.jar -storepass changeit
+   adb root
+   adb remount
+   adb push cacerts.bks /system/etc/security/cacerts.bks
+   adb shell chmod 644 /system/etc/security/cacerts.bks
+   adb reboot
+   ```
+
+7. **Install and Launch the Client**
+
+   ```shell
+   adb install -r "com.aol.mobile.aim_1.10.9.1-138_minAPI3(nodpi).apk"
+   adb shell am start -n com.aol.mobile.aim/com.aol.mobile.aim.ui.AimActivity
+   ```
+
+   Sign in with your Open OSCAR Server screen name and password.
+
+## Troubleshooting
+
+**`sdkmanager` or `avdmanager` reports that it requires JDK 17 or later**
+
+Point `JAVA_HOME` at JDK 17 or later.
+
+**The client can't connect**
+
+Open `https://api.oscar.aol.com/` in the emulator's browser. If successful, you should see the following payload:
+
+```json
+{"response":{"statusCode":200,"statusText":"Ok","data":{}}}
+```
+
+If the page fails to load, the emulator can't reach the server: confirm the server is running, and check the hosts file
+entries from step 5 and the certificate from step 6.

+ 23 - 15
docs/CLIENT_IOS.md

@@ -1,18 +1,26 @@
 # AIM for iOS Client Setup
 
-This guide explains how to connect the AIM for iOS client (v5.1.3) to Open OSCAR Server. The client talks to the
-server's Web API, so setup consists of pointing the app's server URLs at your Open OSCAR Server instance and installing
-the modified app on your device.
+This guide explains how to connect AIM for iOS 5.1.3 to Open OSCAR Server. The client talks to the server's Web API, so
+setup consists of pointing the app's server URLs at your Open OSCAR Server instance and installing the modified app on
+your device.
+
+The SSL-enabled examples in this guide assume your server's hostname is `ras.dev`, the hostname passed to
+`make docker-run`. If you use a different hostname, substitute it wherever `ras.dev` appears.
 
 <p align="center">
    <img width="320" alt="AIM for iOS Screenshot" src="https://github.com/user-attachments/assets/8b410e0e-060f-4626-bda9-e210f4a6548a" />
 </p>
 
+> [!NOTE]
+> Open OSCAR Server is not affiliated with or endorsed by AOL or Yahoo. AIM and AIM for iOS are trademarks of their
+> respective owners. This project does not distribute the AIM for iOS app; you are responsible for ensuring that
+> obtaining and using it complies with the laws that apply to you.
+
 ## Prerequisites
 
 1. **Compatible iOS Device**
 
-   AIM v5.1.3 is a 32-bit app, so it requires a device running iOS 5 through 10. iOS 11 and later do not run 32-bit
+   AIM 5.1.3 is a 32-bit app, so it requires a device running iOS 5 through 10. iOS 11 and later do not run 32-bit
    apps.
 
 2. **Ability to Sideload Apps**
@@ -37,7 +45,7 @@ the modified app on your device.
 
 1. **Acquire the AIM IPA**
 
-   Download AIM v5.1.3 from [archive.org](https://archive.org) by searching for `AOL Instant Messenger iOS`.
+   Download AIM 5.1.3 from [archive.org](https://archive.org) by searching for `AOL Instant Messenger iOS`.
 
 2. **Configure Server URLs**
 
@@ -47,9 +55,8 @@ the modified app on your device.
     - **Unencrypted:** `http://<server-ip>:8081/`, where `<server-ip>` is the LAN IP address or hostname of the
       machine running Open OSCAR Server, and `8081` is the default Web API port (`WEBAPI_LISTENERS`). Do not use
       `127.0.0.1`, which refers to the device itself.
-    - **SSL-enabled:** `https://<OSCAR_HOST>/`, where `<OSCAR_HOST>` is the hostname you passed to
-      `make docker-run` (e.g., `ras.dev`). The device must be able to resolve this hostname, either through DNS or its
-      hosts file.
+    - **SSL-enabled:** `https://ras.dev/`, where `ras.dev` is the hostname you passed to `make docker-run`. The
+      device must be able to resolve this hostname, either through DNS or its hosts file.
 
    <div align="center"><br>
 
@@ -76,26 +83,27 @@ the modified app on your device.
 
 4. **Install the Root CA Certificate (SSL only)**
 
-   If you're running an SSL-enabled setup, the device must trust your server's root CA certificate.
-
-   In Safari on the device, open `http://<OSCAR_HOST>/certs/ca.crt` and install the profile when prompted.
+   The device must trust your server's root CA certificate. In Safari on the device, open `http://ras.dev/certs/ca.crt`
+   and install the profile when prompted.
 
    <p align="center">
       <img width="320" alt="iOS Certificate Installation Screenshot" src="https://github.com/user-attachments/assets/b62aeb01-4d2a-4262-a602-d772f26b94b5" />
    </p>
 
-   On iOS 10.3 and later, you must also enable full trust for the certificate under **Settings → General → About → Certificate
-   Trust Settings**.
+   On iOS 10.3 and later, you must also enable full trust for the certificate under
+   **Settings → General → About → Certificate Trust Settings**.
 
 5. **Sign In**
 
-   Open the AIM app and sign in!
+   Open the AIM app and sign in with your Open OSCAR Server screen name and password.
 
 ## Troubleshooting
 
 **The client can't connect**
 
-Open `{BASE}` in Safari on the device. If successful, you should see the following payload:
+Open `https://ras.dev/` in Safari on the device. If you're running an unencrypted setup or a different hostname, adjust
+the URL to match the base URL you configured in step 2 (for example, `http://192.168.1.10:8081/`). If successful, you
+should see the following payload:
 
 ```json
 {"response":{"statusCode":200,"statusText":"Ok","data":{}}}

+ 325 - 0
docs/CLIENT_MANDARIN.md

@@ -0,0 +1,325 @@
+# Mandarin Client Setup
+
+This guide explains how to build Mandarin 1.9.7, an open-source Android ICQ client, run it in an Android 5.1 emulator,
+and connect it to Open OSCAR Server. The client talks to the server's Web API, so setup consists of pointing the app's
+server URLs at your Open OSCAR Server instance, building it from source, and installing it in the emulator.
+
+The SSL-enabled examples in this guide assume your server's hostname is `ras.dev`, the hostname passed to
+`make docker-run`. If you use a different hostname, substitute it wherever `ras.dev` appears.
+
+<p align="center">
+   <img width="350" alt="Mandarin Screenshot" src="https://github.com/user-attachments/assets/7b53c7cf-dafc-4ec9-829f-27ddbe45afb5" />
+</p>
+
+## Prerequisites
+
+1. **Compatible Host**
+
+   An Intel or Apple Silicon Mac, x86_64 Linux with KVM, or Windows with *Windows Hypervisor Platform* enabled. On
+   Windows, run the commands in PowerShell, not WSL.
+
+2. **JDK 17 or Later and JDK 8**
+
+   The Android SDK tools require JDK 17 or later, and the app builds with JDK 8. Install both, for example from
+   [Eclipse Temurin](https://adoptium.net/) or [Amazon Corretto](https://aws.amazon.com/corretto/), and keep JDK 17 or
+   later as your default `JAVA_HOME`.
+
+   **Linux:**
+
+   On Ubuntu, run the following. On other distributions, install the equivalent packages.
+
+   ```shell
+   sudo apt install openjdk-17-jdk-headless openjdk-8-jdk-headless
+   ```
+
+   Confirm that `java -version` reports version 17. If it reports 1.8, run `sudo update-alternatives --config java`
+   and select JDK 17.
+
+   **macOS:**
+
+   ```shell
+   brew install --cask corretto@8 temurin@17
+   ```
+
+   Confirm that `java -version` reports version 17 or later. If your shell sets `JAVA_HOME`, point it at JDK 17 or
+   later.
+
+   **Windows (PowerShell):**
+
+   ```powershell
+   winget install Amazon.Corretto.8.JDK
+   winget install EclipseAdoptium.Temurin.17.JDK
+   ```
+
+3. **Open OSCAR Server Instance**
+
+   Configure and run an Open OSCAR Server instance using one of the available guides.
+
+    - For a simple **unencrypted** installation, follow the release setup guide for your operating system.
+        - [Windows](./WINDOWS.md)
+        - [Linux](./LINUX.md)
+        - [macOS (Intel & Apple Silicon)](./MACOS.md)
+    - For an advanced **SSL-enabled** installation, follow the [Docker Setup Guide](./DOCKER.md).
+
+   The Web API must be enabled (`WEBAPI_ENABLED=true`, the default).
+
+## Setup Procedure
+
+1. **Set Up the SDK Environment**
+
+   **Linux:**
+
+   ```shell
+   export ANDROID_HOME="$HOME/Android/Sdk"
+   export PATH="$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
+   ```
+
+   ```shell
+   curl -O https://dl.google.com/android/repository/commandlinetools-linux-11479570_latest.zip
+   mkdir -p "$ANDROID_HOME"
+   unzip commandlinetools-linux-11479570_latest.zip -d "$ANDROID_HOME/cmdline-tools"
+   mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
+   ```
+
+   **macOS:**
+
+   ```shell
+   brew install --cask android-commandlinetools
+   ```
+
+   ```shell
+   export ANDROID_HOME="$(brew --prefix)/share/android-commandlinetools"
+   export PATH="$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
+   ```
+
+   **Windows (PowerShell):**
+
+   ```powershell
+   $env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk"
+   $env:Path = "$env:ANDROID_HOME\emulator;$env:ANDROID_HOME\platform-tools;$env:ANDROID_HOME\cmdline-tools\latest\bin;$env:Path"
+   $env:JAVA_HOME = (Get-ChildItem "C:\Program Files\Eclipse Adoptium" -Directory -Filter "jdk-17*").FullName
+   ```
+
+   ```powershell
+   curl.exe -O https://dl.google.com/android/repository/commandlinetools-win-11479570_latest.zip
+   Expand-Archive commandlinetools-win-11479570_latest.zip "$env:ANDROID_HOME\cmdline-tools"
+   Move-Item "$env:ANDROID_HOME\cmdline-tools\cmdline-tools" "$env:ANDROID_HOME\cmdline-tools\latest"
+   ```
+
+2. **Create the Emulator**
+
+   Choose the system image that matches your host: `arm64-v8a` on Apple Silicon and other ARM hosts, or `x86_64`
+   everywhere else. Replace `<ABI>` below with your choice.
+
+   ```shell
+   sdkmanager "platform-tools" "emulator" "platforms;android-22" "platforms;android-27" "build-tools;27.0.3" "system-images;android-22;default;<ABI>"
+   avdmanager create avd -n mandarin_api22 -k "system-images;android-22;default;<ABI>" -d pixel_5
+   ```
+
+   `sdkmanager` prompts you to accept each SDK license; enter `y` to continue. `avdmanager` may print
+   `Could not load devices from ... devices.xml` errors. These are harmless.
+
+3. **Clone the Source**
+
+   ```shell
+   git clone https://github.com/solkin/mandarin-android-legacy.git
+   cd mandarin-android-legacy
+   git checkout 1.9.7
+   ```
+
+4. **Fix the Build Repositories**
+
+   In `build.gradle`, update both `repositories` blocks:
+
+   ```diff
+    buildscript {
+        repositories {
+   -        jcenter()
+            google()
+   +        mavenCentral()
+        }
+        ...
+    }
+
+    allprojects {
+        repositories {
+   -        jcenter()
+            google()
+   +        mavenCentral()
+   +        maven { url 'https://jitpack.io' }
+        }
+    }
+   ```
+
+   In `app/build.gradle`, update the StickyHeaders dependency:
+
+   ```diff
+    dependencies {
+        ...
+        implementation 'se.emilsjolander:stickylistheaders:2.7.0' // TODO: remove
+   -    implementation 'org.zakariya.stickyheaders:stickyheaders:0.7.6'
+   +    implementation 'com.github.ShamylZakariya:StickyHeaders:v0.7.6'
+        implementation 'com.github.machinarius:preferencefragment:0.1.1'
+        ...
+    }
+   ```
+
+5. **Configure Server URLs**
+
+   Edit `app/src/main/java/com/tomclaw/mandarin/im/icq/WimConstants.java`, replacing the existing value of each
+   constant in the table below. `{BASE}` is your server's base URL:
+
+    - **Unencrypted:** `http://<server-ip>:8081/`, where `<server-ip>` is the LAN IP address or hostname of the
+      machine running Open OSCAR Server, and `8081` is the default Web API port (`WEBAPI_LISTENERS`). Do not use
+      `127.0.0.1`, which refers to the emulator itself.
+    - **SSL-enabled:** `https://ras.dev/`, where `ras.dev` is the hostname you passed to `make docker-run`. The
+      emulator must be able to resolve this hostname, either through DNS or its hosts file (step 8).
+
+   <div align="center"><br>
+
+   | Constant            | Value                     |
+   |---------------------|---------------------------|
+   | `CLIENT_LOGIN_URL`  | `{BASE}auth/clientLogin`  |
+   | `START_SESSION_URL` | `{BASE}aim/startSession`  |
+   | `RENEW_TOKEN_URL`   | `{BASE}auth/getInfo`      |
+
+   </div>
+
+   For example, an unencrypted server at `192.168.1.10:8081` gives `START_SESSION_URL` the value
+   `http://192.168.1.10:8081/aim/startSession`.
+
+6. **Build the APK**
+
+   Run the following from the `mandarin-android-legacy` directory to build with JDK 8:
+
+   **Linux:**
+
+   ```shell
+   JAVA_HOME=$(echo /usr/lib/jvm/java-8-openjdk-*) ./gradlew assembleDebug
+   ```
+
+   On distributions other than Ubuntu, replace the path with your JDK 8 install directory.
+
+   **macOS:**
+
+   ```shell
+   JAVA_HOME=$(/usr/libexec/java_home -v 1.8) ./gradlew assembleDebug
+   ```
+
+   **Windows (PowerShell):**
+
+   ```powershell
+   $env:JAVA_HOME = (Get-ChildItem "C:\Program Files\Amazon Corretto" -Directory -Filter "jdk1.8*").FullName
+   .\gradlew.bat assembleDebug
+   ```
+
+   The APK is written to `app/build/outputs/apk/debug/app-debug.apk`.
+
+7. **Start the Emulator**
+
+   ```shell
+   emulator -avd mandarin_api22 -writable-system -no-snapshot-save -no-boot-anim
+   ```
+
+   For an SSL-enabled setup, always start the emulator with `-writable-system`. If the emulator fails to boot, add
+   `-gpu swiftshader_indirect` or `-gpu off`.
+
+8. **Redirect the Server Hostname (SSL only)**
+
+   In a second terminal, map `ras.dev` to the host machine in the emulator's hosts file. If the server runs on a
+   different machine, use that machine's IP address instead of `10.0.2.2`.
+
+   **macOS / Linux:**
+
+   ```shell
+   adb root
+   adb remount
+   adb pull /system/etc/hosts .
+   echo "10.0.2.2  ras.dev" >> hosts
+   adb push hosts /system/etc/hosts
+   ```
+
+   **Windows (PowerShell):**
+
+   ```powershell
+   adb root
+   adb remount
+   adb pull /system/etc/hosts .
+   [IO.File]::AppendAllText("$PWD\hosts", "`n10.0.2.2  ras.dev`n")
+   adb push hosts /system/etc/hosts
+   ```
+
+   Verify with `adb shell ping -c1 ras.dev`.
+
+9. **Install the Root CA Certificate (SSL only)**
+
+   Download your server's root CA certificate from `http://ras.dev/certs/ca.crt`, or copy it from `certs/ca.crt` in
+   your Open OSCAR Server directory. On Windows, use `curl.exe` instead of `curl`.
+
+   ```shell
+   curl -O http://ras.dev/certs/ca.crt
+   ```
+
+   Run the following from the directory containing `ca.crt`. On Windows, `openssl` is included with
+   [Git for Windows](https://gitforwindows.org/) under `C:\Program Files\Git\usr\bin`.
+
+   **macOS / Linux:**
+
+   ```shell
+   HASH=$(openssl x509 -inform PEM -subject_hash_old -in ca.crt -noout)
+   adb root
+   adb remount
+   adb push ca.crt "/system/etc/security/cacerts/$HASH.0"
+   adb shell chmod 644 "/system/etc/security/cacerts/$HASH.0"
+   adb reboot
+   ```
+
+   **Windows (PowerShell):**
+
+   ```powershell
+   $hash = openssl x509 -inform PEM -subject_hash_old -in ca.crt -noout
+   adb root
+   adb remount
+   adb push ca.crt "/system/etc/security/cacerts/$hash.0"
+   adb shell chmod 644 "/system/etc/security/cacerts/$hash.0"
+   adb reboot
+   ```
+
+10. **Install and Launch the Client**
+
+    ```shell
+    adb install -r app/build/outputs/apk/debug/app-debug.apk
+    adb shell am start -n com.tomclaw.mandarin/.main.MainActivity
+    ```
+
+    If you rebuild the app with a different base URL, run `adb uninstall com.tomclaw.mandarin` before installing it.
+    Otherwise, the app keeps its session with the previous server.
+
+    Tap **I already have login and password**, then sign in with your Open OSCAR Server UIN or screen name and
+    password.
+
+## Troubleshooting
+
+**`sdkmanager` or `avdmanager` reports that it requires JDK 17 or later**
+
+Point `JAVA_HOME` at JDK 17 or later. Only the build in step 6 uses JDK 8.
+
+**The build fails before compiling**
+
+If the build reports `Could not determine java version from '<version>'`, Gradle is running on a JDK other than JDK 8.
+Confirm that JDK 8 is installed and that `"$JAVA_HOME/bin/java" -version` reports version 1.8 when run with the
+`JAVA_HOME` value from step 6. On macOS, `/usr/libexec/java_home -v 1.8` must print a path; if it doesn't, install the
+`corretto@8` cask.
+
+**The client can't connect**
+
+Open `https://ras.dev/` in the emulator's browser. If you're running an unencrypted setup or a different hostname,
+adjust the URL to match the base URL you configured in step 5 (for example, `http://192.168.1.10:8081/`). If
+successful, you should see the following payload:
+
+```json
+{"response":{"statusCode":200,"statusText":"Ok","data":{}}}
+```
+
+If the page fails to load, the emulator can't reach the server: check the URL, confirm the server is running, and for
+an SSL-enabled setup, check the hosts file entry from step 8 and the certificate from step 9.

+ 0 - 181
scripts/fetch_aimex.sh

@@ -1,181 +0,0 @@
-#!/usr/bin/env bash
-#
-# fetch_aimex.sh -- download the archived AIM Express client and buddy-icon
-# uploader from the Wayback Machine.
-#
-# Static URL lists. The id_ in each URL is what makes the archive return the
-# ORIGINAL bytes instead of its rewritten viewer page -- omit it and every .swf
-# comes back as HTML. The local path is whatever follows the build directory.
-#
-# Downloads are strictly SERIAL with a delay between every request. The Wayback
-# Machine throttles by refusing connections outright (curl exit 7, not an HTTP
-# 429) and stays angry for a while, so slow beats parallel.
-#
-# Usage:
-#   ./fetch_aimex.sh                 # fetch anything missing under ./clients
-#   ./fetch_aimex.sh -o somewhere    # write somewhere else
-#   ./fetch_aimex.sh -d 5            # be extra polite
-#   ./fetch_aimex.sh --force         # re-download everything
-
-set -uo pipefail
-
-EXPRESS_URLS=(
-    "https://web.archive.org/web/20130905172104id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/AC_OETags.js"
-    "https://web.archive.org/web/20150426081812id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/ConversationAssets.swf"
-    "https://web.archive.org/web/20130905172110id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/OnlinePanel.swf"
-    "https://web.archive.org/web/20150109230526id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/WidgetMain.html"
-    "https://web.archive.org/web/20130905172106id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/WidgetMain.swf"
-    "https://web.archive.org/web/20150115210119id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/_uac/adpage.html"
-    "https://web.archive.org/web/20150426081851id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/aim-express/babybunny.png"
-    "https://web.archive.org/web/20150426081812id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/aim-express/default_buddy_icon.png"
-    "https://web.archive.org/web/20150426081812id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/aim-express/default_imserv_icon.png"
-    "https://web.archive.org/web/20130905172109id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/aim-express/default_login_user_icon.png"
-    "https://web.archive.org/web/20130905172109id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/aim-express/progress_animation.swf"
-    "https://web.archive.org/web/20150426081854id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/check.png"
-    "https://web.archive.org/web/20150426081854id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/facebook_h.png"
-    "https://web.archive.org/web/20150426081854id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/facebook_n.png"
-    "https://web.archive.org/web/20150426081854id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/ls_h.png"
-    "https://web.archive.org/web/20150426081854id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/ls_n.png"
-    "https://web.archive.org/web/20150426081854id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/plus.png"
-    "https://web.archive.org/web/20150426081855id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/twitter_h.png"
-    "https://web.archive.org/web/20150426081854id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/twitter_n.png"
-    "https://web.archive.org/web/20150426081855id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/youtube_h.png"
-    "https://web.archive.org/web/20150426081855id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/chicklets/youtube_n.png"
-    "https://web.archive.org/web/20150426081812id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/all.gif"
-    "https://web.archive.org/web/20150426081813id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/facebook.gif"
-    "https://web.archive.org/web/20150426081813id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/lifestream.gif"
-    "https://web.archive.org/web/20150426081812id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/location.gif"
-    "https://web.archive.org/web/20150426081813id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/myspace.gif"
-    "https://web.archive.org/web/20150426081812id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/photo.gif"
-    "https://web.archive.org/web/20150426081812id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/status.gif"
-    "https://web.archive.org/web/20150426081813id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/twitter.gif"
-    "https://web.archive.org/web/20150426081812id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/filterTypes/video.gif"
-    "https://web.archive.org/web/20100829133330id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/help/aim_or_fb.html"
-    "https://web.archive.org/web/20130905172109id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/common/serviceicons/runningman.png"
-    "https://web.archive.org/web/20150426081812id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/loadable/emoticons/GromitEmoticons.swf"
-    "https://web.archive.org/web/20150426081812id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/norris.txt"
-    "https://web.archive.org/web/20150115211959id_/http://o.aolcdn.com/aim/gromit/aim_express/gm/100820.5475.1.en-us/portal.php"
-)
-
-ICONUPLOADER_URLS=(
-    "https://web.archive.org/web/20151127135337id_/http://o.aolcdn.com/aim/gromit/iconuploader/110128.1.5797/Main.html"
-    "https://web.archive.org/web/20160126020222id_/http://o.aolcdn.com/aim/gromit/iconuploader/110128.1.5797/Main.swf"
-    "https://web.archive.org/web/20120815104026id_/http://o.aolcdn.com/aim/gromit/iconuploader/110128.1.5797/redir.html"
-    "https://web.archive.org/web/20160126020219id_/http://o.aolcdn.com/aim/gromit/iconuploader/110128.1.5797/swfobject.js"
-)
-
-OUTDIR="clients"
-DELAY=2.5
-MAX_RETRIES=5
-FORCE=0
-
-while [ $# -gt 0 ]; do
-    case "$1" in
-        -o|--outdir)      OUTDIR=$2; shift 2 ;;
-        -d|--delay)       DELAY=$2; shift 2 ;;
-        -r|--max-retries) MAX_RETRIES=$2; shift 2 ;;
-        --force)          FORCE=1; shift ;;
-        -h|--help)        sed -n '3,19p' "$0" | sed 's/^#\{0,1\} \{0,1\}//'; exit 0 ;;
-        *)                echo "unknown option: $1" >&2; exit 1 ;;
-    esac
-done
-
-TMP=$(mktemp -d "${TMPDIR:-/tmp}/gromit.XXXXXX") || exit 1
-trap 'rm -rf "$TMP"' EXIT
-trap 'echo; echo "interrupted -- re-run to resume (existing files are skipped)"; exit 130' INT
-
-TOTAL=$(( ${#EXPRESS_URLS[@]} + ${#ICONUPLOADER_URLS[@]} ))
-INDEX=0
-REQUESTS=0
-N_OK=0; N_SKIP=0; N_FAIL=0
-
-bump_delay() {
-    DELAY=$(awk -v d="$DELAY" 'BEGIN { v = d*1.5+1; printf "%.1f", (v > 30 ? 30 : v) }')
-    echo "      $1 -- floor delay now ${DELAY}s"
-}
-
-# fetch URL OUTFILE LABEL
-fetch() {
-    local url=$1 out=$2 label=$3
-    local attempt=0 code rc wait reason
-
-    while :; do
-        sleep "$DELAY"
-        code=$(curl -sS --max-time 120 -D "$TMP/hdr" \
-                    -o "$out.part" -w '%{http_code}' "$url" 2>/dev/null)
-        rc=$?
-        REQUESTS=$((REQUESTS+1))
-
-        # -s: a zero-byte body is a failed capture, not a file.
-        if [ "$rc" -eq 0 ] && [ "$code" = "200" ] && [ -s "$out.part" ]; then
-            mv "$out.part" "$out"
-            return 0
-        fi
-        rm -f "$out.part"
-
-        wait=""
-        if [ "$rc" -ne 0 ]; then
-            reason="curl exit $rc"
-            # 7 = couldn't connect: we tripped the throttle. Back off hard and
-            # keep the floor raised for the rest of the run.
-            case "$rc" in
-                7|28|56) bump_delay "connection refused/timed out" ;;
-            esac
-        elif [ "$code" = "200" ]; then
-            reason="empty response"
-        else
-            reason="HTTP $code"
-            if [ "$code" = "404" ]; then
-                echo "      !! 404 $label"
-                return 1
-            fi
-            if [ "$code" = "429" ]; then
-                bump_delay "rate limited"
-                wait=$(awk 'tolower($1) ~ /^retry-after:/ {gsub(/\r/,""); print $2; exit}' "$TMP/hdr")
-                case "$wait" in ''|*[!0-9]*) wait="" ;; esac
-            fi
-        fi
-
-        if [ "$attempt" -ge "$MAX_RETRIES" ]; then
-            echo "      !! $label: giving up after $((attempt+1)) attempts ($reason)"
-            return 1
-        fi
-        [ -n "$wait" ] || wait=$(awk -v d="$DELAY" -v a="$attempt" \
-            'BEGIN { v = d*(2^a)+2; print int(v > 120 ? 120 : v) }')
-        attempt=$((attempt+1))
-        echo "      $reason -- retry $attempt/$MAX_RETRIES in ${wait}s"
-        sleep "$wait"
-    done
-}
-
-# download SUBDIR BUILD_ID URL...
-download() {
-    local subdir=$1 build=$2
-    shift 2
-    local url rel dest
-    for url in "$@"; do
-        rel=${url##*/$build/}
-        dest="$OUTDIR/$subdir/$rel"
-        INDEX=$((INDEX+1))
-        if [ "$FORCE" -eq 0 ] && [ -s "$dest" ]; then
-            printf '[%2d/%d] skip  %s/%s\n' "$INDEX" "$TOTAL" "$subdir" "$rel"
-            N_SKIP=$((N_SKIP+1))
-            continue
-        fi
-        printf '[%2d/%d] get   %s/%s\n' "$INDEX" "$TOTAL" "$subdir" "$rel"
-        mkdir -p "$(dirname "$dest")"
-        if fetch "$url" "$dest" "$rel"; then
-            echo "      $(wc -c < "$dest" | tr -d ' ') bytes"
-            N_OK=$((N_OK+1))
-        else
-            N_FAIL=$((N_FAIL+1))
-        fi
-    done
-}
-
-download express      100820.5475.1.en-us "${EXPRESS_URLS[@]}"
-download iconuploader 110128.1.5797       "${ICONUPLOADER_URLS[@]}"
-
-printf '\n%s ok, %s skipped, %s failed  (%s requests)\n' \
-    "$N_OK" "$N_SKIP" "$N_FAIL" "$REQUESTS"
-exit 0