# 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)

# Android 注音動態鍵盤

這是一個 Android 輸入法原型，目標是做出接近 iOS 注音動態鍵盤體驗的注音鍵盤。

[English README](README.en.md)

本專案為獨立開發的 Android 注音輸入法。

除 README 與 `NOTICE.md` 明確列出的第三方資料外，本專案未包含任何第三方程式碼、專有詞庫、商標素材或其他受版權保護內容。

## 目前功能

- 接近 iOS 風格的動態注音輸入流程。
- 注音鍵盤與英文鍵盤按鍵位置固定，避免輸入時鍵位跳動。
- 使用產生後的注音候選字典查詢候選字。
- 支援連續多音節組字、完整片語候選、逐音節組句保底，以及「一／不」變調還原。
- 支援注音、英文、數字、符號輸入模式。
- 一聲與空白鍵邏輯合併，不另外顯示一聲按鍵。

## OpenAI Build Week：Codex 與 GPT-5.6

這是一個在 OpenAI Build Week 前就已能運作的既有專案。活動期間使用 Codex 與 GPT-5.6 協助延伸與穩定現有鍵盤，而不是把既有成果包裝成全新的專案。

活動期間的工作重點包括：

- 檢查現有 Android／Kotlin 程式碼並找出高影響問題。
- 改善注音 composing text、候選列與輸入可靠性。
- 完成本機使用者詞典的暫停學習、清除、重設、匯入與匯出流程。
- 改善大型詞典處理、transaction 安全性與建置驗證。
- 實作每輸入一個注音符號即更新候選、連續組句與片語候選保底。
- 修正密碼、網址、搜尋與一般文字欄位的輸入模式判斷。
- 以單元測試、模擬 `InputConnection`、Android 模擬器與實機反覆驗證輸入行為。
- 保留活動期間的 commit 與工作紀錄，區分活動前 baseline 與活動期間新增成果。

所有模型產生的修改都會經過人工檢查、實際建置與裝置測試後才接受。更完整的英文說明請看 [README.en.md](README.en.md#openai-build-week-codex-and-gpt-56)。

## 建置與安裝

需求：

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

在 Windows PowerShell 執行：

```powershell
.\gradlew.bat testDebugUnitTest lintDebug assembleDebug
```

產生的 APK 位於：

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

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

```powershell
.\gradlew.bat installDebug
```

安裝後開啟「動態注音鍵盤」應用程式：

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

## 評審快速測試

- 連續輸入多個注音音節，觀察動態候選、片語候選與逐音節組句保底。
- 選取不同候選後再次輸入相同讀音，確認本機候選學習會調整排序。
- 開啟主應用程式，測試暫停／清除學習、手動詞彙及字典匯入／匯出。
- 在密碼欄確認鍵盤固定使用英文且不學習；在 Chrome 網址列確認仍可輸入中文搜尋。
- 本專案不宣告網路權限，所有輸入與候選學習都在裝置本機完成。

## 字典資料

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

- `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`
- `app/src/main/assets/zhuyin_cedict_LICENSE.txt`
- `tools/data/README.md`

## 隱私

本鍵盤目前不需要網路權限，輸入內容在裝置本機處理，不會上傳。

注意:沒接網路　字典就簡單的你用越多次的字／詞越前面　


隱私權政策請看：

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

## 參考資料

本專案為獨立實作。

開發過程中曾參考多種公開可取得的中文輸入法與語言資源，以了解一般輸入流程、鍵盤互動設計及注音輸入習慣。

除 `NOTICE.md` 明確列出的第三方資料外，本 repository 不包含任何第三方程式碼、專有詞庫、視覺素材或其他受版權保護內容。

目前候選字典合併 CC-CEDICT 衍生詞條與 McBopomofo 片語讀音，並使用 McBopomofo 彙總詞頻排序。鍵盤注音 glyph 使用 ToneOZ Pinyin WenKai 的固定版本 subset。來源、轉換方式與授權請參閱 `NOTICE.md`。

該 subset 包含 U+3105-U+3129 與鍵盤使用的五個聲調符號（包含一聲 U+02C9），SHA-256 為 `7d2630c930012253c214100dae4fdccef582ed02be6bcbc313bed831ad672800`。

## 授權狀態

除另有標示者外，本專案的原始程式碼採用 [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)。

## 開發提醒

如果之後要新增任何字典、詞頻表、鍵盤素材或其他第三方資料，請先確認授權，並在提交前同步更新 `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.]