# Project export: dynamic-zhuyin-keyboard

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: OpenAI Build Week
- Tagline: A privacy-first Taiwanese Zhuyin keyboard for Android
- Devpost: https://devpost.com/software/dynamic-zhuyin-keyboard
- GitHub: https://github.com/RaibowSky/dynamic-zhuyin-keyboard
- Video: https://www.youtube.com/embed/4Xk1wNyMgY4?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — RaibowSky (17 commits)

## Devpost submission (written by the team)

### Inspiration

Most Android Zhuyin keyboards are either difficult to customize, rely heavily on cloud services, or do not feel natural for Taiwanese users. I wanted a privacy-first keyboard that works offline and can be adjusted to match the user's typing habits.

### What it does

Dynamic Zhuyin Keyboard is an Android keyboard designed for Traditional Chinese users in Taiwan. It supports customizable key layout, on-device user dictionary learning, candidate suggestions, and learning-data management without requiring network access.

### How we built it

The keyboard is built with Kotlin for Android. Codex and GPT-5.6 were used to review the existing codebase, implement new features, improve user-dictionary behavior, test builds, and document the development process.

### Challenges we ran into

The main challenges were handling Zhuyin composition correctly, ranking candidates naturally, keeping learning data safe, and making import/export reliable for larger dictionaries.

### Accomplishments we're proud of

We created a working offline Zhuyin keyboard with customizable layout and local learning. During the hackathon, we focused on improving the user dictionary, learning controls, import/export, and typing reliability.

### What we learned

We learned that keyboard quality depends on many small details, including composition behavior, candidate ranking, layout spacing, and data safety. AI-assisted development was most useful when paired with repeated testing on a real device.

### What's next

Next, we plan to improve composing text and candidate ranking, add more Taiwanese vocabulary, refine the UI, expand testing, and prepare a stable public release.

## README (from the GitHub repository)

# 動態注音鍵盤（Dynamic Zhuyin Keyboard）

## 📥 透過 Google Play 參加測試與下載

**需要 Android 7.0 以上。請使用同一個 Google 帳號依序完成以下步驟：**

1. [加入測試者 Google 群組](https://groups.google.com/g/dynamic-zhuyin-keyboard)。
2. [開啟 Google Play 測試頁面](https://play.google.com/apps/testing/dynamic.zhuyin.keyboard)，選擇加入測試。
3. [前往 Google Play 商店下載](https://play.google.com/store/apps/details?id=dynamic.zhuyin.keyboard)。

若測試頁面或商店顯示無法存取，請先確認已加入群組、完成加入測試，且網頁與手機的 Google Play 使用同一個帳號。
安裝後開啟「動態注音鍵盤」，依序點選「啟用鍵盤」與「切換鍵盤」。

### 直接下載 APK

也可[下載 Android APK（v1.3.1）](https://github.com/RaibowSky/dynamic-zhuyin-keyboard/releases/download/v1.3.1/dynamic-zhuyin-1.3.1.apk)，下載後開啟 `.apk` 安裝。

[最新版本與更新說明](https://github.com/RaibowSky/dynamic-zhuyin-keyboard/releases/latest) · [安裝與舊版升級說明](#安裝)

若從 Releases 頁面下載，請展開 **Assets** 並選擇 `.apk` 檔；`Source code` 是原始碼，不能直接安裝。

一套在 Android 裝置本機運作的注音（Bopomofo／ㄅㄆㄇ）輸入法，採用「動態鍵盤」
設計，目標是提供接近 iOS 注音輸入法的輸入體驗，同時保持完全離線、注重隱私。

[English README](README.en.md)

## 這是什麼？

「動態注音鍵盤」把注音符號放在固定位置，輸入時只更新候選列與可用的下一鍵，
減少鍵位跳動，並以裝置本機的詞庫提供候選字／詞。

## 為什麼做這個？

- 提供繁體中文使用者更接近 iOS 注音輸入法的動態鍵盤手感。
- 完全離線：不宣告網路權限，輸入內容與候選學習都在裝置本機完成。
- 注重隱私：不蒐集、不上傳打字內容。

## 主要功能

- 動態注音鍵盤：按鍵位置固定，輸入時不跳位。
- 注音候選查詢：使用本地產生的注音候選字典。
- 連續整句解碼：組合多個已知詞與單字，支援「一／不」變調與保留後綴的首字修正。
- 注音、英文、數字與符號頁；「ABC」／「注」在同一套鍵盤內一鍵往返。
- 系統淺色／深色主題、可預覽的本機 TTF／OTF 字型匯入與預設字型還原。
- 在「字體大小、鍵盤高度與外觀」調整注音字大小（16–40）並即時預覽；注音／聲調與候選字可分開調整。
- Emoji 分為表情、手勢、動物、飲食、活動、旅行、符號七類，使用 ◀／▶ 翻頁；空白鍵旁顯示目前頁碼。
- 本機候選學習：常用的字／詞會隨著使用往前排。
- 使用者詞典：支援手動詞彙、暫停／清除學習、匯入／匯出。
- 一聲與空白鍵邏輯合併，不另外顯示一聲按鍵。
- 未選字的注音可點入原文或長按空白左右滑動游標，從中間補字、刪除或改聲調；移動游標不會自動選第一組候選。游標移出注音範圍時保留原注音文字。

## 安裝

Google Play 測試版請依照[上方步驟](#-透過-google-play-參加測試與下載)加入群組、參加測試並安裝。
正式簽署 APK 與 checksum 請見 [Releases](https://github.com/RaibowSky/dynamic-zhuyin-keyboard/releases/latest)。
Android 7.0 以上可安裝。1.1.1 起內建英文鍵盤，不再需要另一套輸入法來輸入英文。

GitHub 直接下載的 APK 與 Google Play 版是兩個獨立的 App，請擇一安裝：

- GitHub APK：應用程式 ID `com.ioszhuyin.keyboard`，可直接覆蓋更新先前的 GitHub 版本，資料會保留。
- Google Play：應用程式 ID `dynamic.zhuyin.keyboard`，由 Google Play 更新。

兩者可同時安裝但資料不互通，也不能互相覆蓋更新。

舊 Build Week debug 版簽章不同，轉換前請先匯出字典，再移除舊版、安裝正式版。
詳見[發版與版本規則](docs/RELEASING.md)。

也可以從原始碼自行建置：

## 建置與安裝

需求：

- JDK 17
- Android SDK 36.1
- Android Build Tools 36.1.0
- Android 7.0（API 24）以上的裝置或模擬器

在 Windows PowerShell 執行：

```powershell
.\gradlew.bat testGithubDebugUnitTest lintGithubDebug assembleGithubDebug
```

產生的 APK 位於：

```text
app/build/outputs/apk/github/debug/app-github-debug.apk
```

Debug APK 使用開發測試簽章，套件名稱為 `com.ioszhuyin.keyboard.debug`，顯示為「動態注音鍵盤（測試版）」。可與正式版並存；設定與字典各自保存。

也可以連接已開啟 USB 偵錯的 Android 裝置後直接安裝：

```powershell
.\gradlew.bat installGithubDebug
```

安裝後開啟「動態注音鍵盤（測試版）」應用程式：

1. 點選「啟用鍵盤」，在 Android 輸入法設定中啟用「動態注音鍵盤」。
2. 返回應用程式並點選「切換鍵盤」，選擇「動態注音鍵盤」。
3. 在任意文字欄位輸入注音；候選列會隨每個注音符號更新。

## 螢幕截圖

Android 模擬器實際執行畫面：

![淺色鍵盤與候選列](docs/images/keyboard-light.png)
![深色鍵盤與候選列](docs/images/keyboard-dark.png)

![字型匯入預覽](docs/images/font-preview.png)

## Roadmap 與已知限制

- 1.2.0 起：長按空白左右滑動移動游標、全／半形符號、常用 Emoji、剪貼簿歷史／釘選與公開版面調整。剪貼簿歷史需自行開啟，未釘選最多 50 筆、超過 1 小時者於下次使用剪貼簿時清除；釘選不自動清除。詳見 [功能與驗證](docs/KEYBOARD-FEEDBACK.md)。
- 整句排序採用離線詞頻順序、詞長與本機偏好，沒有網路模型。另提供小型常見接續詞與本機學習的下一詞提示，尚非完整語境預測。
- 每個音節位置保留最多 9 條解碼路徑，詞邊最多 16 音節；整句長度沒有 3 音節限制。
- 自訂字型支援 TTF／OTF（上限 20 MB）；缺字會退回內建／系統字型。系統字型清單仍待研究。
- 裝置廠牌、較舊 Android 版本與實體裝置的持續測試仍需要回報。
- 目前進度請見 [Issues](https://github.com/RaibowSky/dynamic-zhuyin-keyboard/issues) 與 [CHANGELOG](CHANGELOG.md)。

## 隱私

本鍵盤不宣告網路權限，輸入內容在裝置本機處理，不會上傳。候選學習與使用者詞典
也都保存在裝置本機。

隱私權政策請看：

- `PrivacyPolicy.zh-TW.md`
- `PrivacyPolicy.md`

## 字典資料

目前實際打包在專案中的候選字典是：

- `app/src/main/assets/zhuyin_cedict.tsv`

這份檔案合併兩種已標示來源的資料：CC-CEDICT 的繁體詞條與拼音讀音會轉成注音查詢鍵；
McBopomofo 的多字片語讀音會補充台灣常用詞，並使用其彙總詞頻排列候選。原始語料不會
打包進 App。

轉換腳本：

- `tools/build_zhuyin_dictionary.py`
- `tools/rank_zhuyin_dictionary.py`
- `tools/merge_mcbopomofo_dictionary.py`

資料來源與授權請看：

- `NOTICE.md`
- `NOTICE.zh-TW.md`
- `app/src/main/assets/zhuyin_cedict_LICENSE.txt`
- `tools/data/README.md`

## 回報問題與貢獻

發現 bug 或想提功能建議，請到
[Issues](https://github.com/RaibowSky/dynamic-zhuyin-keyboard/issues) 開新的 issue。

貢獻前請注意：

- 先確認 issue 尚未有人處理，並在 issue 中說明你的計畫。
- 新增第三方資料、詞庫、字型或素材前，請先確認授權，並同步更新 `NOTICE.md` 與
  `NOTICE.zh-TW.md`（包含來源網址、取得日期、授權條款與轉換方式）。
- 修改後請執行 `.\gradlew.bat testGithubDebugUnitTest lintGithubDebug assembleGithubDebug` 確認測試、
  lint 與建置通過。
- 發 PR 前請與 `main` 同步，並清楚說明改動範圍。

## 專案歷史

本專案起源於 OpenAI Build Week 期間，以 Codex 與 GPT-5.6 協助延伸與穩定一個
活動前就已能運作的 Android 注音鍵盤原型；既有成果保留為 baseline，活動期間新增的
功能與修正則逐項記錄於 commit 歷史。

所有模型產生的修改都經過人工檢查、實際建置與裝置測試後才接受。這段歷史保留在本節
作為透明紀錄，不作為專案目前的中心定位。

## 授權

除另有標示者外，本專案的原始程式碼採用 [Apache License 2.0](LICENSE)。

根目錄的 Apache-2.0 不會重新授權第三方資料。由 CC-CEDICT 轉換而來的候選資料遵守
CC BY-SA 4.0；McBopomofo 片語讀音與彙總詞頻資料遵守其 MIT License 與上游資料聲明；
ToneOZ 字型 subset 遵守 SIL Open Font License 1.1。完整來源、轉換方式與授權副本請見
[NOTICE.zh-TW.md](NOTICE.zh-TW.md) 與 [NOTICE.md](NOTICE.md)。


## Detected evidence (automated analysis)

Indexed codebase: 40 recognized source files, 327 KB.
- Kotlin (language) — detected in the code
- Python (language) — detected in the code

## Codebase structure (from repository index)

### Files (56 of 56)

```
.gitattributes
.gitignore
app/build.gradle.kts
app/proguard-rules.pro
app/src/main/AndroidManifest.xml
app/src/main/assets/bopomofo_FONT_NOTICE.txt
app/src/main/assets/bopomofo_OFL.txt
app/src/main/assets/zhuyin_cedict_LICENSE.txt
app/src/main/assets/zhuyin_cedict.tsv
app/src/main/java/com/ioszhuyin/keyboard/CandidateLearning.kt
app/src/main/java/com/ioszhuyin/keyboard/ImeBehavior.kt
app/src/main/java/com/ioszhuyin/keyboard/IOSZhuyinIME.kt
app/src/main/java/com/ioszhuyin/keyboard/KeyboardMetrics.kt
app/src/main/java/com/ioszhuyin/keyboard/MainActivity.kt
app/src/main/java/com/ioszhuyin/keyboard/UserDictionaryStore.kt
app/src/main/java/com/ioszhuyin/keyboard/ZhuyinComposition.kt
app/src/main/java/com/ioszhuyin/keyboard/ZhuyinDictionary.kt
app/src/main/java/com/ioszhuyin/keyboard/ZhuyinDynamicLayout.kt
app/src/main/java/com/ioszhuyin/keyboard/ZhuyinKeyboardView.kt
app/src/main/res/drawable/ic_launcher_foreground.xml
app/src/main/res/drawable/ic_launcher.xml
app/src/main/res/drawable/info_box_bg.xml
app/src/main/res/layout/activity_main.xml
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
app/src/main/res/values/colors.xml
app/src/main/res/values/strings.xml
app/src/main/res/values/themes.xml
app/src/main/res/xml/backup_rules.xml
app/src/main/res/xml/data_extraction_rules.xml
app/src/main/res/xml/ioskeyboard.xml
app/src/test/java/com/ioszhuyin/keyboard/ImeBehaviorTest.kt
app/src/test/java/com/ioszhuyin/keyboard/SortedTsvDictionaryTest.kt
app/src/test/java/com/ioszhuyin/keyboard/UserDictionaryImportPolicyTest.kt
app/src/test/java/com/ioszhuyin/keyboard/ZhuyinCompositionTest.kt
build.gradle.kts
gradle.properties
gradle/wrapper/gradle-wrapper.properties
gradlew
gradlew.bat
LICENSE
NOTICE.md
NOTICE.zh-TW.md
PrivacyPolicy.md
PrivacyPolicy.zh-TW.md
README.en.md
README.md
settings.gradle.kts
tools/build_bopomofo_font.py
tools/build_zhuyin_dictionary.py
tools/data/mcbopomofo_bpmf_mappings.txt
tools/data/McBopomofo_LICENSE.txt
tools/data/mcbopomofo_phrase.occ
tools/data/README.md
tools/merge_mcbopomofo_dictionary.py
tools/rank_zhuyin_dictionary.py
tools/requirements-fonts.txt
```

### Dependencies

No dependency index available.

### Recent commits (newest first)

- feat: expand dictionary and improve candidate prediction
- feat: refine punctuation and keyboard responsiveness
- Update README.md
- feat: refine keyboard behavior and Build Week readiness
- fix: harden IME reliability and add behavior tests
- fix: preserve composition when keyboard hides
- feat: match iOS number and symbol pages
- feat: show Zhuyin composition inline
- feat: adapt return key labels to editor actions
- feat: add iOS-style candidate swiping
- feat: align iOS controls and candidate browsing
- feat: improve continuous Zhuyin composition
- feat: complete candidate learning and privacy controls
- feat: rank dictionary with attributed frequency data
- fix: harden IME reliability and add behavior tests
- docs: add Build Week development disclosure
- docs: document Codex and GPT-5.6 Build Week usage
- Add user dictionary and keyboard calibration tools
- Merge Zhuyin tone candidate variants
- Align final-page medial key columns

## Key source files (fetched from GitHub, selected and truncated for size)

### PrivacyPolicy.zh-TW.md

```markdown
# 隱私權政策

最後更新：2026-07-17

本政策適用於「動態注音鍵盤」Android 輸入法。

## 摘要

- 本鍵盤目前不要求網路權限。
- 輸入內容在裝置本機處理，不會上傳到伺服器。
- 本鍵盤目前沒有廣告、分析工具或第三方追蹤。
- 本鍵盤目前沒有整合雲端同步或崩潰回報服務。

## 輸入內容

作為輸入法，本鍵盤需要在裝置上處理使用者輸入的注音、候選字選擇與文字輸出。這些處理目前都在本機完成。

本鍵盤不會透過網路傳送使用者輸入的文字、候選字、密碼、帳號或其他輸入內容。

## 本機資料

本鍵盤可能在裝置本機儲存候選字選擇頻率，用於調整候選字排序。本應用程式不會將這份資料上傳。

本應用程式透過 `allowBackup=false` 停用 Android 雲端備份，並在備份規則中排除 Android 雲端備份及 Android 裝置對裝置轉移所支援的所有應用程式資料範圍。由於本專案沒有可配對的 iOS 應用程式，未設定 Android 16 QPR2 的跨平台轉移規則；本政策不宣稱該模式已停用。

一般匯出只包含使用者手動新增的詞彙，不包含候選學習紀錄。使用者可以另外選擇匯出學習紀錄；選擇時會先看到個資風險提示。該匯出檔不會加密，可能包含人名、地址或其他私人用詞，應由使用者妥善保管且避免任意分享。學習紀錄匯出內容不包含選取時間。

設定畫面提供暫停／繼續候選學習及清除學習紀錄的控制。暫停學習只會停止新增紀錄，既有排序紀錄仍保留並繼續在一般文字欄位使用；密碼欄位及要求禁止個人化學習的欄位不會記錄或套用個人化候選排序。

## 權限

目前使用的權限：

- `VIBRATE`：用於按鍵觸覺回饋。
- `BIND_INPUT_METHOD`：Android 輸入法服務所需的系統權限。

目前不使用：

- 網路權限。
- 廣告識別碼。
- 聯絡人、位置、相機、麥克風、檔案讀取等敏感權限。

## 第三方服務

本鍵盤目前沒有使用第三方分析、廣告、雲端候選字、雲端同步或崩潰回報服務。

## 字典資料

本鍵盤打包的字典合併 CC-CEDICT 衍生詞條與讀音，以及 McBopomofo 的多字片語讀音；合併後的候選使用離線 McBopomofo 彙總詞頻資料排序。這些資料都在本機處理，不會於執行時連線查詢。資料來源與授權請參考 `NOTICE.md`。

## 聯絡

如對本政策或專案有疑問，請透過 GitHub repository 聯絡專案維護者。

```

### PrivacyPolicy.md

```markdown
# Privacy Policy

Last updated: 2026-07-17

This policy applies to the Android input method "Dynamic Zhuyin Keyboard".

## Summary

- The keyboard currently does not request network permission.
- Typed content is processed locally on the device and is not uploaded.
- The keyboard currently has no ads, analytics, or third-party tracking.
- The keyboard currently has no cloud sync or crash reporting integration.

## Typed Content

As an input method, the keyboard processes Zhuyin input, candidate selection,
and text output on the device. This processing is currently local only.

The keyboard does not transmit typed text, candidates, passwords, account
information, or other input content over the network.

## Local Data

The keyboard may store local candidate selection frequency on the device to
adjust candidate ordering. The app does not upload this data. Android cloud
backup is disabled with `allowBackup=false`, and the backup rules exclude every
supported app-data domain from Android cloud backup and Android-to-Android
device transfer. Android 16 QPR2 cross-platform transfer is not configured
because this project has no paired iOS app; this policy does not claim that
mode is disabled.

Standard exports contain only words manually added by the user and exclude
candidate-learning records. A user may explicitly choose to include learning
records after seeing a privacy warning. The exported file is not encrypted and
may contain names, addresses, or other private terms, so it should be stored
carefully and not shared casually. Learning-record exports do not include
selection timestamps.

The settings screen provides controls to pause or resume candidate learning and
to clear learning records. Pausing stops new records while retaining and using
existing ordering in ordinary text fields. Password fields and editors that
request no personalized learning neither record nor apply personalized
candidate ordering.

## Permissions

Permissions currently used:

- `VIBRATE`: used for key press haptic feedback.
- `BIND_INPUT_METHOD`: required by Android for input method services.

Permissions not currently used:

- Network access.
- Advertising ID.
- Contacts, location, camera, microphone, file access, or other sensitive
  permissions.

## Third-Party Services

The keyboard currently does not use third-party analytics, ads, cloud
candidates, cloud sync, or crash reporting services.

## Dictionary Data

The bundled dictionary combines CC-CEDICT-derived entries and readings with
McBopomofo multi-character phrase readings. Offline McBopomofo aggregate
phrase-frequency data ranks the merged candidates. These resources are
processed locally without runtime network lookup. See `NOTICE.md` for source
and license attribution.

## Contact

For questions about this policy or the project, please contact the maintainer
through the GitHub repository.

```

### build.gradle.kts

```kotlin
plugins {
    id("com.android.application") version "8.13.2" apply false
    kotlin("android") version "1.9.0" apply false
}

```

### settings.gradle.kts

```kotlin
pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}
rootProject.name = "IOSZhuyinKeyboard"
include(":app")
```

### app/build.gradle.kts

```kotlin
plugins {
    id("com.android.application")
    kotlin("android")
}

android {
    namespace = "com.ioszhuyin.keyboard"
    compileSdk {
        version = release(36) {
            minorApiLevel = 1
        }
    }
    buildToolsVersion = "36.1.0"

    defaultConfig {
        applicationId = "com.ioszhuyin.keyboard"
        minSdk = 24
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    kotlinOptions {
        jvmTarget = "17"
    }

    testOptions {
        unitTests {
            isIncludeAndroidResources = false
            isReturnDefaultValues = false
        }
    }

    androidResources {
        noCompress += "tsv"
    }
}

dependencies {
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("com.google.android.material:material:1.11.0")
    implementation("androidx.constraintlayout:constraintlayout:2.1.4")

    testImplementation("junit:junit:4.13.2")
}

```

### tools/rank_zhuyin_dictionary.py

```python
"""Re-rank an existing generated Zhuyin dictionary reproducibly."""

import os
from pathlib import Path

from build_zhuyin_dictionary import load_phrase_frequencies, rank_candidates


def main() -> None:
    root = Path(__file__).resolve().parents[1]
    dictionary = root / "app" / "src" / "main" / "assets" / "zhuyin_cedict.tsv"
    frequency_source = root / "tools" / "data" / "mcbopomofo_phrase.occ"
    frequencies = load_phrase_frequencies(frequency_source)
    if not frequencies:
        raise SystemExit(f"No frequency data found in {frequency_source}")

    temporary = dictionary.with_suffix(".tsv.tmp")
    rows = 0
    with dictionary.open("r", encoding="utf-8") as source, temporary.open(
        "w", encoding="utf-8", newline="\n"
    ) as target:
        attribution_written = False
        for line in source:
            if line.startswith("#"):
                if "McBopomofo phrase.occ" in line:
                    attribution_written = True
                target.write(line.rstrip("\n") + "\n")
                continue
            if not attribution_written:
                target.write("# Candidates ranked with McBopomofo phrase.occ frequency data\n")
                attribution_written = True
            clean = line.rstrip("\n")
            if not clean:
                continue
            key, separator, values = clean.partition("\t")
            if not separator:
                raise ValueError(f"Invalid dictionary row: {clean[:80]}")
            candidates = values.split()
            target.write(f"{key}\t{' '.join(rank_candidates(candidates, frequencies))}\n")
            rows += 1

    os.replace(temporary, dictionary)
    print(f"ranked_rows={rows} frequencies={len(frequencies)} target={dictionary}")


if __name__ == "__main__":
    main()

```

### tools/build_bopomofo_font.py

```python
import argparse
import hashlib
import tempfile
from pathlib import Path


UPSTREAM_COMMIT = "55facb136a7b22afd60ddf30ac0226661614d870"
UPSTREAM_URL = (
    "https://raw.githubusercontent.com/jeffreyxuan/"
    f"toneoz-font-pinyin-wenkai/{UPSTREAM_COMMIT}/"
    "fonts/ttf/ToneOZ-Pinyin-WenKai-Regular.ttf"
)
UPSTREAM_SHA256 = "153a826f06fd6d578adfd7235c72d3b5298698a319a48ff088dff43bd87c83e8"
OUTPUT_SHA256 = "7d2630c930012253c214100dae4fdccef582ed02be6bcbc313bed831ad672800"
REQUIRED_FONTTOOLS_VERSION = "4.63.0"
UNICODES = "U+02C7,U+02C9,U+02CA,U+02CB,U+02D9,U+3105-3129"


def sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def main() -> None:
    root = Path(__file__).resolve().parents[1]
    parser = argparse.ArgumentParser(description="Build the bundled Bopomofo font subset")
    parser.add_argument(
        "--source",
        type=Path,
        default=root / "ToneOZ-Pinyin-WenKai-Regular.ttf",
        help=f"Pinned upstream font downloaded from {UPSTREAM_URL}",
    )
    parser.add_argument(
        "--target",
        type=Path,
        default=root / "app" / "src" / "main" / "assets" / "bopomofo.ttf",
    )
    args = parser.parse_args()

    if not args.source.exists():
        raise FileNotFoundError(
            f"Upstream font not found: {args.source}\nDownload the pinned file from {UPSTREAM_URL}"
        )
    source_hash = sha256(args.source)
    if source_hash != UPSTREAM_SHA256:
        raise ValueError(
            f"Unexpected upstream font SHA-256: expected {UPSTREAM_SHA256}, got {source_hash}"
        )

    try:
        import fontTools
        from fontTools import subset
    except ImportError as error:
        raise RuntimeError(
            "Install the pinned font tooling with "
            "python -m pip install -r tools/requirements-fonts.txt"
        ) from error
    if fontTools.__version__ != REQUIRED_FONTTOOLS_VERSION:
        raise RuntimeError(
            f"fonttools {REQUIRED_FONTTOOLS_VERSION} is required; found {fontTools.__version__}"
        )

    args.target.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile(
        prefix="bopomofo-",
        suffix=".ttf",
        dir=args.target.parent,
        delete=False,
    ) as temporary:
        temporary_path = Path(temporary.name)
    temporary_path.unlink()
    try:
        subset.main(
            [
                str(args.source),
                f"--output-file={temporary_path}",
                f"--unicodes={UNICODES}",
                "--glyph-names",
                "--symbol-cmap",
                "--legacy-cmap",
                "--name-IDs=*",
                "--name-languages=*",
                "--name-legacy",
                "--layout-features=*",
                "--no-recalc-timestamp",
            ]
        )
        output_hash = sha256(temporary_path)
        if output_hash != OUTPUT_SHA256:
            raise ValueError(
                f"Unexpected subset SHA-256: expected {OUTPUT_SHA256}, got {output_hash}"
            )
        temporary_path.replace(args.target)
    finally:
        temporary_path.unlink(missing_ok=True)

    print(f"source={args.source} target={args.target} sha256={OUTPUT_SHA256}")


if __name__ == "__main__":
    main()

```

### tools/merge_mcbopomofo_dictionary.py

```python
"""Merge pinned McBopomofo phrase readings into the bundled Zhuyin dictionary."""

import argparse
import hashlib
from pathlib import Path

from build_zhuyin_dictionary import (
    append_unique,
    load_phrase_frequencies,
    rank_candidates,
    strip_tones,
)


MCBOPOMOFO_COMMIT = "14f672cd9296deb4ff87034b05003b15a1e796f5"
MCBOPOMOFO_MAPPINGS_SHA256 = (
    "51715ee5c731f9994be3b168d7681ed9692b8c335ae6fc2e7cfd4af6fd0e0781"
)
MAX_CANDIDATES_PER_KEY = 128


def verify_sha256(source: Path, expected: str) -> None:
    if not source.exists():
        raise FileNotFoundError(f"McBopomofo mappings not found: {source}")
    actual = hashlib.sha256(source.read_bytes()).hexdigest()
    if actual != expected:
        raise ValueError(
            f"Unexpected McBopomofo mappings SHA-256: expected {expected}, got {actual}"
        )


def load_dictionary(source: Path) -> dict[str, list[str]]:
    entries: dict[str, list[str]] = {}
    with source.open("r", encoding="utf-8") as file:
        for line_number, line in enumerate(file, start=1):
            clean = line.rstrip("\n")
            if not clean or clean.startswith("#"):
                continue
            key, separator, raw_candidates = clean.partition("\t")
            if not separator or not key or not raw_candidates:
                raise ValueError(f"Invalid dictionary row at line {line_number}")
            for candidate in raw_candidates.split():
                append_unique(entries, key, candidate)
    if not entries:
        raise ValueError(f"Dictionary is empty: {source}")
    return entries


def merge_mappings(
    entries: dict[str, list[str]],
    source: Path,
) -> tuple[int, int, int]:
    verify_sha256(source, MCBOPOMOFO_MAPPINGS_SHA256)
    original_keys = len(entries)
    original_pairs = sum(len(candidates) for candidates in entries.values())
    parsed = 0

    with source.open("r", encoding="utf-8") as file:
        for line_number, line in enumerate(file, start=1):
            parts = line.strip().split()
            if len(parts) < 2:
                raise ValueError(f"Invalid BPMFMappings row at line {line_number}")
            phrase, *syllables = parts
            toned_key = "".join(syllables)
            untoned_key = strip_tones(toned_key)
            if not phrase or not toned_key or not untoned_key:
                raise ValueError(f"Invalid BPMFMappings row at line {line_number}")
            append_unique(entries, toned_key, phrase)
            append_unique(entries, untoned_key, phrase)
            parsed += 1

    added_keys = len(entries) - original_keys
    added_pairs = sum(len(candidates) for candidates in entries.values()) - original_pairs
    return parsed, added_keys, added_pairs


def write_dictionary(
    target: Path,
    entries: dict[str, list[str]],
    frequencies: dict[str, int],
) -> None:
    target.parent.mkdir(parents=True, exist_ok=True)
    with target.open("w", encoding="utf-8", newline="\n") as file:
        file.write("# CC-CEDICT converted to Zhuyin by tools/build_zhuyin_dictionary.py\n")
        file.write(
            "# Extended with McBopomofo BPMFMappings.txt and ranked with phrase.occ\n"
        )
        file.write(f"# McBopomofo pinned commit: {MCBOPOMOFO_COMMIT}\n")
        file.write("# key<TAB>candidate1 candidate2 ...\n")
        for key in sorted(entries, key=lambda value: (len(value), value)):
            candidates = rank_candidates(entries[key], frequencies)
            file.write(f"{key}\t{' '.join(candidates[:MAX_CANDIDATES_PER_KEY])}\n")


def main() -> None:
    root = Path(__file__).resolve().parents[1]
    parser = argparse.ArgumentParser(
        description="Build a comparison dictionary extended with McBopomofo phrases"
    )
    parser.add_argument(
        "--base",
        type=Path,
        default=root / "app" / "src" / "main" / "assets" / "zhuyin_cedict.tsv",
    )
    parser.add_argument(
        "--mappings",
        type=Path,
        default=root / "tools" / "data" / "mcbopomofo_bpmf_mappings.txt",
    )
    parser.add_argument(
        "--target",
        type=Path,
        default=root / "build" / "dictionary-comparison" / "zhuyin_cedict.tsv",
    )
    args = parser.parse_args()

    frequencies = load_phrase_frequencies(root / "tools" / "data" / "mcbopomofo_phrase.occ")
    entries = load_dictionary(args.base)
    parsed, added_keys, added_pairs = merge_mappings(entries, args.mappings)
    write_dictionary(args.target, entries, frequencies)
    digest = hashlib.sha256(args.target.read_bytes()).hexdigest()
    print(
        f"parsed_mappings={parsed} added_keys={added_keys} added_pairs={added_pairs} "
        f"total_keys={len(entries)} bytes={args.target.stat().st_size} "
        f"sha256={digest} target={args.target}"
    )


if __name__ == "__main__":
    main()

```

### tools/build_zhuyin_dictionary.py

```python
import argparse
import gzip
import hashlib
import re
from collections import OrderedDict
from pathlib import Path


MCBOPOMOFO_PHRASE_OCC_SHA256 = (
    "0fc51c5245a8820e1003e3fa3fb2759b0d1b502a71da81bbfa265e9ac6c9fb5a"
)
# The CC-CEDICT URL serves MDBG's latest release and is not immutable. The
# archive checksum below pins the accepted rebuild input. It is newer than, and
# must not be presented as the source snapshot of, the currently bundled asset;
# see NOTICE.md for that provenance limitation.
CC_CEDICT_SOURCE_URL = (
    "https://www.mdbg.net/chinese/export/cedict/"
    "cedict_1_0_ts_utf-8_mdbg.txt.gz"
)
CC_CEDICT_RETRIEVED_DATE = "2026-07-15"
CC_CEDICT_ARCHIVE_SHA256 = (
    "33d79ec1cc91fd1bc76fe7e590723d474cfe6ab364648eef9b7b52677e897d87"
)


TONE = {"1": "ˉ", "2": "ˊ", "3": "ˇ", "4": "ˋ", "5": "˙", "0": "˙"}
INITIALS = OrderedDict(
    [
        ("zh", "ㄓ"),
        ("ch", "ㄔ"),
        ("sh", "ㄕ"),
        ("b", "ㄅ"),
        ("p", "ㄆ"),
        ("m", "ㄇ"),
        ("f", "ㄈ"),
        ("d", "ㄉ"),
        ("t", "ㄊ"),
        ("n", "ㄋ"),
        ("l", "ㄌ"),
        ("g", "ㄍ"),
        ("k", "ㄎ"),
        ("h", "ㄏ"),
        ("j", "ㄐ"),
        ("q", "ㄑ"),
        ("x", "ㄒ"),
        ("r", "ㄖ"),
        ("z", "ㄗ"),
        ("c", "ㄘ"),
        ("s", "ㄙ"),
    ]
)

FINALS = {
    "": "",
    "a": "ㄚ",
    "o": "ㄛ",
    "e": "ㄜ",
    "ê": "ㄝ",
    "eh": "ㄝ",
    "ai": "ㄞ",
    "ei": "ㄟ",
    "ao": "ㄠ",
    "ou": "ㄡ",
    "an": "ㄢ",
    "en": "ㄣ",
    "ang": "ㄤ",
    "eng": "ㄥ",
    "er": "ㄦ",
    "i": "ㄧ",
    "ia": "ㄧㄚ",
    "ie": "ㄧㄝ",
    "iao": "ㄧㄠ",
    "iu": "ㄧㄡ",
    "ian": "ㄧㄢ",
    "in": "ㄧㄣ",
    "iang": "ㄧㄤ",
    "ing": "ㄧㄥ",
    "u": "ㄨ",
    "ua": "ㄨㄚ",
    "uo": "ㄨㄛ",
    "uai": "ㄨㄞ",
    "ui": "ㄨㄟ",
    "uan": "ㄨㄢ",
    "un": "ㄨㄣ",
    "uang": "ㄨㄤ",
    "ong": "ㄨㄥ",
    "ueng": "ㄨㄥ",
    "v": "ㄩ",
    "ve": "ㄩㄝ",
    "van": "ㄩㄢ",
    "vn": "ㄩㄣ",
    "iong": "ㄩㄥ",
}

ZERO_INITIAL = {
    "yi": "ㄧ",
    "ya": "ㄧㄚ",
    "yo": "ㄧㄛ",
    "ye": "ㄧㄝ",
    "yai": "ㄧㄞ",
    "yao": "ㄧㄠ",
    "you": "ㄧㄡ",
    "yan": "ㄧㄢ",
    "yin": "ㄧㄣ",
    "yang": "ㄧㄤ",
    "ying": "ㄧㄥ",
    "wu": "ㄨ",
    "wa": "ㄨㄚ",
    "wo": "ㄨㄛ",
    "wai": "ㄨㄞ",
    "wei": "ㄨㄟ",
    "wan": "ㄨㄢ",
    "wen": "ㄨㄣ",
    "wang": "ㄨㄤ",
    "weng": "ㄨㄥ",
    "yu": "ㄩ",
    "yue": "ㄩㄝ",
    "yuan": "ㄩㄢ",
    "yun": "ㄩㄣ",
    "yong": "ㄩㄥ",
}

LINE_RE = re.compile(r"^(\S+)\s+(\S+)\s+\[([^\]]+)\]\s+/.*/$")
PINYIN_RE = re.compile(r"^([a-zA-ZüÜ:êÊ]+)([0-5])$")


def convert_syllable(raw: str) -> str | None:
    raw = raw.strip().lower().replace("u:", "v").replace("ü", "v")
    match = PINYIN_RE.match(raw)
    if not match:
        return None

    body, tone = match.groups()
    if body in ZERO_INITIAL:
        return ZERO_INITIAL[body] + TONE[tone]

    initial = ""
    initial_zhuyin = ""
    for candidate, zhuyin in INITIALS.items():
        if body.startswith(candidate):
            initial = candidate
            initial_zhuyin = zhuyin
            break
    final = body[len(initial) :]

    if initial in {"zh", "ch", "sh", "r", "z", "c", "s"} and final == "i":
        final = ""
    elif initial in {"j", "q", "x"} and final.startswith("u"):
        final = "v" + final[1:]
    elif initial in {"n", "l"}:
        final = final.replace("v", "v")

    final_zhuyin = FINALS.get(final)
    if final_zhuyin is None:
        return None
    return initial_zhuyin + final_zhuyin + TONE[tone]


def strip_tones(zhuyin: str) -> str:
    return zhuyin.translate(str.maketrans("", "", "ˉ˙ˊˇˋ"))


def append_unique(bucket: dict[str, list[str]], key: str, value: str) -> None:
    if not key or not value:
        return
    values = bucket.setdefault(key, [])
    if value not in values:
        values.append(value)


def load_phrase_frequencies(source: Path) -> dict[str, int]:
    if not source.exists():
        raise FileNotFoundError(f"McBopomofo frequency source not found: {source}")
    digest = hashlib.sha256(source.read_bytes()).hexdigest()
    if digest != MCBOPOMOFO_PHRASE_OCC_SHA256:
        raise ValueError(
            "Unexpected McBopomofo phrase.occ SHA-256: "
            f"expected {MCBOPOMOFO_PHRASE_OCC_SHA256}, got {digest}"
        )

    frequencies: dict[str, int] = {}
    with source.open("r", encoding="utf-8") as file:
        for line_number, line in enumerate(file, start=1):
            parts = line.rstrip("\n").rsplit(maxsplit=1)
            if len(parts) != 2:
                raise ValueError(f"Invalid phrase.occ row at line {line_number}")
            phrase, raw_count = parts
            try:
                count = int(raw_count)
            except ValueError as error:
                raise ValueError(
                    f"Invalid phrase.occ count at line {line_number}: {raw_count}"
                ) from error
            if not phrase or count < 0:
                raise ValueError(f"Invalid phrase.occ row at line {line_number}")
            frequencies[phrase] = max(frequencies.get(phrase, 0), count)
    if not frequencies:
        raise ValueError(f"McBopomofo frequency source is empty: {source}")
    return frequencies


def verify_source(source: Path, expected_sha256: str, label: str) -> None:
    if not source.exists():
        raise FileNotFoundError(f"{label} source not found: {source}")
    digest = hashlib.sha256(source.read_bytes()).hexdigest()
    if digest != expected_sha256:
        raise ValueError(
            f"Unexpected {label} SHA-256: expected {expected_sha256}, got {digest}"
        )


def rank_candidates(candidates: list[str], frequencies: dict[str, int]) -> list[str]:
    # Python's sort is stable, so candidates with equal or unknown frequency
    # retain CC-CEDICT's original order.
    return sorted(candidates, key=lambda candidate: -frequencies.get(candidate, 0))


def main() -> None:
    root = Path(__file__).resolve().parents[1]
    rebuild_output = root / "build" / "dictionary-rebuild"
    parser = argparse.ArgumentParser(
        description="Build the pinned Zhuyin d
[truncated — 4503 more characters]
```

### app/src/main/AndroidManifest.xml

```xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.VIBRATE" />

    <application
        android:allowBackup="false"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:label="@string/app_name"
        android:icon="@drawable/ic_launcher"
        android:theme="@style/Theme.IOSKeyboard">

        <activity
            android:name=".MainActivity"
            android:label="@string/app_name"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <service
            android:name=".IOSZhuyinIME"
            android:label="@string/ime_label"
            android:permission="android.permission.BIND_INPUT_METHOD"
            android:exported="true">
            <intent-filter>
                <action android:name="android.view.InputMethod" />
            </intent-filter>
            <meta-data
                android:name="android.view.im"
                android:resource="@xml/ioskeyboard" />
        </service>

    </application>
</manifest>

```

[27 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]