From a71fb544d3ba8af2e72df396b15940cec154cf51 Mon Sep 17 00:00:00 2001 From: Timmy Date: Fri, 17 Apr 2026 13:07:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20Android=20=E7=89=88?= =?UTF-8?q?=E9=96=8B=E7=99=BC=E9=81=8E=E7=A8=8B=20DEVLOG=EF=BC=88=E5=BE=9E?= =?UTF-8?q?=E9=81=B8=E5=9E=8B=E5=88=B0=20Gitea=20release=20=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E7=B4=80=E9=8C=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/DEVLOG.md | 280 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 android/DEVLOG.md diff --git a/android/DEVLOG.md b/android/DEVLOG.md new file mode 100644 index 0000000..78dda8b --- /dev/null +++ b/android/DEVLOG.md @@ -0,0 +1,280 @@ +# 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` 存輸入 | `mutableStateMapOf` | +| `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` 加 `` 宣告並支援觸控缺席情境,但這 app 核心互動是輸入金額,TV 遙控器體驗會很差,不建議。