# Android 版開發過程

記錄這個專案從 Rust 桌面版延伸到 Android 平板的完整過程、踩到的坑、以及為什麼做出這些選擇。主線需求：**不想裝 Android Studio**（2GB+ 太肥），全程 CLI 完成。

## 階段 0：決策與選型

### 候選方案比較

| 方案 | 工具鏈重量 | 重寫成本 | 體驗 | 結論 |
|------|-----------|---------|------|------|
| Rust + `cargo-apk`（沿用現有 `main.rs`） | 中（SDK + NDK） | 低 | egui 觸控差、IME 不順 | ✗ |
| **Kotlin + Jetpack Compose（CLI）** | 中（SDK 300MB） | 低（核心邏輯 ~50 行） | 原生最佳 | ✓ |
| Flutter / Tauri Mobile | 重（再引入一整個生態） | 中 | 不差 | overkill |
| PWA / 網頁版 | 零 | 低 | 差；**台銀 CSV 沒開 CORS 直接擋掉** | ✗ |

選 Kotlin + Compose，因為邏輯極簡（抓 CSV → parse col 13 → 乘法），Compose 的宣告式 UI 跟 egui 心智模型相近，轉換幾乎是 1:1。

### 「不用 Android Studio」的真正意思

Android Studio 是 IDE，背後其實只靠三個 CLI 工具：

1. **JDK 17**（`brew install openjdk@17`）
2. **Android SDK**（`cmdline-tools` + `platform-tools` + `platforms;android-34` + `build-tools;34.0.0`，用 `sdkmanager` 裝）
3. **Gradle**（`brew install gradle`）

IDE 提供的 Compose live preview / refactor / debugger 在這個小專案用不上。每次 `./gradlew installDebug` 直接在實機看結果反而更真實。

## 階段 1：專案骨架

手工建 Gradle 多模組專案（實際只一個模組 `:app`），關鍵檔案：

```
android/
├── settings.gradle.kts          # 宣告 :app module + pluginManagement repos
├── build.gradle.kts             # 宣告 plugins（AGP / Kotlin / Compose compiler）
├── gradle.properties            # JVM args / AndroidX / nonTransitiveRClass
├── app/
│   ├── build.gradle.kts         # compileSdk 34, minSdk 26, Compose BOM
│   └── src/main/
│       ├── AndroidManifest.xml
│       ├── res/values/strings.xml
│       └── java/tw/local/botrates/MainActivity.kt
```

### 版本鎖定

```
AGP 8.5.2 + Kotlin 2.0.20 + Compose BOM 2024.09.02
```

Kotlin 2.0 開始 Compose compiler 改走**獨立 Gradle plugin** `org.jetbrains.kotlin.plugin.compose`，不再需要寫 `composeOptions { kotlinCompilerExtensionVersion = "..." }`。

### `minSdk = 26`（Android 8.0+）

26 以下要額外處理 `java.time` 降級 / desugaring，且目前 Android 裝置 8.0 以下普及率已很低，不值得為此複雜化設定。

## 階段 2：把 Rust 邏輯 port 成 Kotlin

核心檔案 `MainActivity.kt`（~230 行）。對照 Rust 版的轉換：

| Rust (egui) | Kotlin (Compose) |
|------------|------------------|
| `ureq::AgentBuilder` | `HttpURLConnection`（標準函式庫夠用，不引 OkHttp） |
| `thread::spawn` + `mpsc::channel` | `rememberCoroutineScope()` + `withContext(Dispatchers.IO)` |
| `App` struct + `impl eframe::App` | `@Composable fun App()` + `remember { mutableStateOf(...) }` |
| `HashMap<String, String>` 存輸入 | `mutableStateMapOf<String, String>` |
| `ui.output_mut(|o| o.copied_text = ...)` | `ClipboardManager.setPrimaryClip` + `Toast` |
| `setup_fonts` 執行期讀 OS 字型 | 免；Android 有 CJK 內建 |

CSV 解析邏輯一模一樣：`cols.size >= 14`、`cols[0]` 幣別代碼、`cols[13]` 即期賣出、過濾 TARGETS。`parseAmount` 和 `formatTwd` 也是直接翻譯。

### 版面取捨

egui 版是橫排 `[幣別+匯率] [輸入] → [TWD] [複製]`。平板雖然寬，但 Compose 版改成**兩列**：第一列 `[幣別+匯率] [輸入框 fillMaxWidth]`，第二列 `[TWD 靠左 weight(1)] [複製鈕]`。理由是輸入框能吃滿寬度，手指點選體驗好，且日後也能直接塞進手機直式螢幕不用改版。

## 階段 3：工具鏈安裝與首次 build

這台 Mac 已經裝好的：

- JDK 17（Temurin）
- `~/Library/Android/sdk`（含 platforms android-34、build-tools 34.0.0、platform-tools、cmdline-tools）

**只缺 Gradle**：

```bash
brew install gradle       # 裝到 9.4.1
```

### `local.properties` 指向 SDK

```
sdk.dir=/Users/timmy/Library/Android/sdk
```

此檔案 **gitignored**，每個開發者自己的 SDK 路徑不同。

### 產 Gradle wrapper（只做一次）

```bash
gradle wrapper --gradle-version 8.9 --distribution-type bin
```

之後所有指令用 `./gradlew`，不再依賴系統 gradle。wrapper 檔案進 repo，確保每個 clone 下來的人用同一個 Gradle 版本。

### 第一次 debug build

```bash
./gradlew assembleDebug
```

耗時 **2:13**（下載 AGP / Compose 依賴約 300MB，之後增量建置 < 10 秒）。產物：

```
app/build/outputs/apk/debug/app-debug.apk   # 8.7 MB（未簽章）
```

## 階段 4：Samsung Tab A 實機部署（USB 偵錯坑）

機型 **SM-T510（Galaxy Tab A 10.1）**。

### 症狀演進

```bash
$ adb devices
List of devices attached
        # 空
```

接著各種症狀：

1. `system_profiler SPUSBDataType` 看不到 Android 裝置 → 確定是連線層
2. 換線後 `adb devices` 出現 `R52N60RZLNW unauthorized`
3. 平板螢幕**不跳**授權對話框
4. 設定 → 開發人員選項 → 撤銷 USB 偵錯授權 → 拔插 → **還是不跳**
5. **關掉 USB 偵錯 → 再開 USB 偵錯** → 對話框終於跳出 → 允許 → `device`

### 教訓

Samsung（至少這台的 One UI）的 USB 偵錯授權對話框有時會被卡在某個內部狀態。標準的「撤銷授權 + 拔插」有時沒用，**「關掉 USB 偵錯再重開」**似乎會強制重設授權管理器的狀態。這條排除步驟以後要記住。

另外需要同時滿足：

- 螢幕解鎖且保持亮著（鎖屏狀態下對話框不顯示）
- USB 模式是「檔案傳輸」而非「僅供電」
- 線支援資料傳輸（不是純充電線）

### 安裝與啟動

```bash
adb install -r app/build/outputs/apk/debug/app-debug.apk   # Streamed Install, Success
adb shell monkey -p tw.local.botrates -c android.intent.category.LAUNCHER 1
```

跑起來字型正常（不用 bundle CJK 字型，系統內建 Noto Sans CJK）、匯率正確抓取、複製貼到其他 app 成功。

## 階段 5：Release 簽章

Android 的 release APK 必須簽章才能安裝（debug 版系統會自動用 debug keystore 簽，release 必須自備）。

### 產生 keystore

```bash
keytool -genkey -v \
  -keystore ~/.android/botrates-release.keystore \
  -alias botrates \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -storepass botrates1234 -keypass botrates1234 \
  -dname "CN=Timmy, OU=Personal, O=BotRates, L=Taipei, ST=Taipei, C=TW"
```

**關鍵決策**：keystore 檔案放 **repo 外**（`~/.android/`），密碼寫在 **gitignored 的 `android/keystore.properties`**。這樣 repo 本身永遠不含 signing secrets，也方便未來換 CI 時用 env var 改寫。

### `build.gradle.kts` 的條件式 signing config

```kotlin
val keystoreProps = Properties().apply {
    val f = rootProject.file("keystore.properties")
    if (f.exists()) f.inputStream().use { load(it) }
}

android {
    signingConfigs {
        if (keystoreProps.isNotEmpty()) {
            create("release") {
                storeFile = file(keystoreProps.getProperty("storeFile"))
                storePassword = keystoreProps.getProperty("storePassword")
                keyAlias = keystoreProps.getProperty("keyAlias")
                keyPassword = keystoreProps.getProperty("keyPassword")
            }
        }
    }
    buildTypes {
        release {
            if (keystoreProps.isNotEmpty()) {
                signingConfig = signingConfigs.getByName("release")
            }
        }
    }
}
```

沒有 `keystore.properties`（例如別人 clone repo）時**不會 fail**，只是 `assembleRelease` 會產出 unsigned APK 並失敗於安裝階段。這比硬噴 `FileNotFoundException` 友善。

### 建置結果

```bash
./gradlew assembleRelease    # 52s
```

| 版本 | 大小 |
|------|------|
| `app-debug.apk` | 8.7 MB |
| `app-release.apk` | **6.5 MB** |

release 小一截是因為 AGP 對 release build 做了 resource shrinking 和 bytecode optimization（雖然 `isMinifyEnabled = false`，AGP 還是做了一些預設處理）。

驗證簽章：

```bash
~/Library/Android/sdk/build-tools/34.0.0/apksigner verify --verbose app-release.apk
# Verifies
# Verified using v2 scheme (APK Signature Scheme v2): true
```

v2 簽章 = 現代 Android（7.0+）官方推薦格式。

## 階段 6：Gitea Release

### Tag + push

```bash
git tag -a v1.0 -m "v1.0 首次 release"
git push --tags
```

### 用 Gitea API 建 release

Gitea 的 release API 分兩步：

1. `POST /api/v1/repos/{owner}/{repo}/releases` 建 release object（帶 tag name）
2. `POST /api/v1/repos/{owner}/{repo}/releases/{id}/assets` 上傳附件

```bash
curl -X POST "http://.../api/v1/repos/timmy/bot-rates/releases" \
  -H "Authorization: token $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tag_name":"v1.0","name":"v1.0 — 首版 Android APK","body":"..."}'
# → 回傳 JSON 含 release.id

curl -X POST "http://.../api/v1/repos/timmy/bot-rates/releases/$ID/assets?name=BotRates-v1.0.apk" \
  -H "Authorization: token $TOKEN" \
  -F "attachment=@android/app/build/outputs/apk/release/app-release.apk"
```

### 踩到小坑

第一次用 `python3 -c "import sys,json; print(json.load(sys.stdin)['id'])"` 解析 curl 回傳時 python JSON decoder 對 body 裡的裸 `\n` 字元噴 `Invalid control character`。原因是 release body 裡的 markdown 換行在 HTTP response 是真正的 `0x0A`，而 Python JSON 規範要求字串值裡的 control char 要 escape。

**解法**：改用 `/releases/tags/{tag}` endpoint 反查 id，body 短所以沒這問題。或 `curl --data-binary` 傳 body 時讓 server 自己處理 escape。

## 成果總覽

| 項目 | 數值 |
|------|------|
| 新增原始碼 | ~230 行 Kotlin（`MainActivity.kt`） |
| 新增設定檔 | ~9 個 Gradle / Manifest / XML |
| 工具鏈總大小 | JDK 17（~300MB）+ Android SDK（~1GB，已有）+ Gradle（~200MB） |
| Debug APK | 8.7 MB |
| Release APK | **6.5 MB**（v2 簽章） |
| 實機驗證 | Samsung Galaxy Tab A 10.1（SM-T510，Android 11） |
| 首次冷啟動 | < 1 秒 |
| 抓匯率耗時 | < 500ms |
| Gitea release | v1.0 已上傳 APK |

## 如果要擴充

- **相容 minSdk 21（Android 5.0）**：`compileOptions` 加 `isCoreLibraryDesugaringEnabled = true`，dependencies 加 `coreLibraryDesugaring`，讓 `java.time` 等新 API 降級。
- **Play Store 上架**：Play Store 要 **AAB**（Android App Bundle）不是 APK，改 `./gradlew bundleRelease`，輸出 `.aab`。另外 Play 會強制要求 targetSdk 跟上最新（目前 34）。
- **CI 簽章**：把 `keystore.properties` 的四個欄位改成讀 env var（`System.getenv()`），keystore 本身用 base64 塞進 GitHub Actions secret 再解碼還原。
- **Wear OS / Android TV**：`AndroidManifest.xml` 加 `<uses-feature>` 宣告並支援觸控缺席情境，但這 app 核心互動是輸入金額，TV 遙控器體驗會很差，不建議。
