Files
bot-rates/android/DEVLOG.md

281 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Android 版開發過程
記錄這個專案從 Rust 桌面版延伸到 Android 平板的完整過程、踩到的坑、以及為什麼做出這些選擇。主線需求:**不想裝 Android Studio**2GB+ 太肥),全程 CLI 完成。
## 階段 0決策與選型
### 候選方案比較
| 方案 | 工具鏈重量 | 重寫成本 | 體驗 | 結論 |
|------|-----------|---------|------|------|
| Rust + `cargo-apk`(沿用現有 `main.rs` | 中SDK + NDK | 低 | egui 觸控差、IME 不順 | ✗ |
| **Kotlin + Jetpack ComposeCLI** | 中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 # 宣告 pluginsAGP / 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 17Temurin
- `~/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未簽章
```
## 階段 4Samsung Tab A 實機部署USB 偵錯坑)
機型 **SM-T510Galaxy 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 成功。
## 階段 5Release 簽章
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 簽章 = 現代 Android7.0+)官方推薦格式。
## 階段 6Gitea 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 反查 idbody 短所以沒這問題。或 `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.1SM-T510Android 11 |
| 首次冷啟動 | < 1 秒 |
| 抓匯率耗時 | < 500ms |
| Gitea release | v1.0 已上傳 APK |
## 如果要擴充
- **相容 minSdk 21Android 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 遙控器體驗會很差,不建議。