11sp
+ 12sp
14sp
16sp
18sp
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index db440796..a4e9a6c7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -5,9 +5,9 @@
英文
本地拼音输入法\n词库和语音都在设备内运行
在这里试打
- 启用
+ 启用输入法
已启用
- 切换
+ 切换到 openIME
已切换到 openIME
启用后即可开始输入
已启用,切换后就能打字
@@ -15,12 +15,12 @@
输入 nihao 得到 你好。点候选上屏,点空格选首选。
语音输入需要麦克风权限。音频只在本机处理,不会上传。可选择授权,也可以跳过后继续试打。
未获得麦克风授权,语音输入无法使用。授权后即可长按空格说话。
- 授权
+ 语音输入(可选)
前往系统设置
跳过
已跳过,可随时授权
- 已授权
- 麦克风已授权,语音输入可以使用。长按空格开始说话,松手即可识别。
+ 麦克风已授权
+ 长按空格开始说话,松手即可识别。
试打
试打完成
打开偏好设置
diff --git a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt
index 152cae36..f99ac6f5 100644
--- a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt
+++ b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt
@@ -66,11 +66,11 @@ class CandidatePipelineTest {
)
assertEquals("ni hao", resolution.preview)
- assertEquals(listOf("64'426"), resolution.pinyinPaths)
+ assertEquals(listOf("ni'426"), resolution.pinyinPaths)
assertTrue("ni hao" in resolution.displayPinyinPaths)
assertFalse("Suffix-only choices would drop ni when selected", resolution.candidates.contains("好"))
assertTrue(resolution.candidates.contains("你好"))
- assertEquals(resolution.candidates, pipeline.nineKeyFallbackCandidatesFor("64'426"))
+ assertEquals(resolution.candidates, pipeline.nineKeyFallbackCandidatesFor("ni'426"))
}
@Test
@@ -115,6 +115,7 @@ class CandidatePipelineTest {
@Test
fun nativeCodeKeepsExplicitSegmentationAndRejectsGarbage() {
assertEquals("64'426", NineKeyLocalDecoder.nativeCode("ni ", "426"))
+ assertEquals("ni'426", NineKeyLocalDecoder.nativeCode("ni ", "426", lockLetters = true))
assertEquals("94'26'426", NineKeyLocalDecoder.nativeCode("xi an ", "426"))
assertNotNull(NineKeyLocalDecoder.nativeCode("", "64426"))
assertNull(NineKeyLocalDecoder.nativeCode("你 ", "426"))
@@ -134,4 +135,88 @@ class CandidatePipelineTest {
val deleted = "64426".removeRange(3, 4)
assertEquals("6446", deleted)
}
+
+ @Test
+ fun lockedSyllableReachesNativeAsLettersSoZhongAndXiongStayDistinct() {
+ val locked = pipeline.resolveNineKey(
+ digits = "94664",
+ segmentPrefix = "",
+ preferredSuffix = "xiong",
+ fuzzy = false,
+ lockPreferred = true,
+ )
+ assertEquals(listOf("xiong"), locked.pinyinPaths)
+
+ val open = pipeline.resolveNineKey(
+ digits = "94664",
+ segmentPrefix = "",
+ preferredSuffix = "xiong",
+ fuzzy = false,
+ )
+ assertEquals(listOf("94664"), open.pinyinPaths)
+ }
+
+ @Test
+ fun shortInputOffersWholeReadingsAndTheWordReadingLeads() {
+ val readings = pipeline.nineKeyReadingsFor("64426", null)
+ assertEquals("ni'hao", readings.first().display)
+ assertTrue(readings.all { it.coversAll })
+ assertTrue("mi'hao" in readings.map { it.display })
+ assertTrue("ni'gao" in readings.map { it.display })
+ // A lone vowel between syllables is a digit-grid artefact, not a reading.
+ assertTrue(readings.none { reading -> reading.syllables.any { it.length == 1 } })
+ assertEquals(readings.map { it.display }.distinct(), readings.map { it.display })
+ }
+
+ @Test
+ fun readingPreviewLeadsWhenItIsOneOfTheReadings() {
+ val readings = pipeline.nineKeyReadingsFor("64426", "migao")
+ assertEquals("mi'gao", readings.first().display)
+ }
+
+ @Test
+ fun longInputOffersFirstSyllablesOnlyAndNeverADeadEnd() {
+ val readings = pipeline.nineKeyReadingsFor("9694264244326", null)
+ assertTrue(readings.isNotEmpty())
+ assertTrue(readings.all { it.syllables.size == 1 })
+ assertEquals("wo", readings.first().syllables.single())
+ }
+
+ @Test
+ fun aLoneDigitOffersLettersThatAreOrientationOnly() {
+ val readings = pipeline.nineKeyReadingsFor("9", null)
+ assertEquals(listOf("w", "x", "y", "z"), readings.map { it.display })
+ assertTrue(readings.none { it.complete })
+ assertTrue(pipeline.nineKeyReadingsFor("", null).isEmpty())
+ }
+
+ @Test
+ fun previewReadingFollowsTheWordNotTheDecoderGuess() {
+ // The unit environment only has the compact lexicon; the full sentence
+ // (我想吃饭 -> wo xiang chi fan) is covered end to end on a device.
+ assertEquals(listOf("zhong", "guo"), pipeline.nineKeyReadingFor("94664486", "中国"))
+ assertEquals(listOf("ni", "hao"), pipeline.nineKeyReadingFor("64426", "你好"))
+ // Digits must be consumed exactly: a shorter word or another spelling is no reading.
+ assertNull(pipeline.nineKeyReadingFor("64426", "你"))
+ assertNull(pipeline.nineKeyReadingFor("64426", "我想"))
+ assertNull(pipeline.nineKeyReadingFor("64426", "nihao"))
+ }
+
+ @Test
+ fun wordsTheDigitsSpellExactlyComeBeforePredictions() {
+ // 64 spells ni/mi: 你 fits; 你好 needs three more digits (a prediction).
+ assertEquals(
+ listOf("你", "你好"),
+ pipeline.preferExactNineKeyMatches("64", listOf("你好", "你")),
+ )
+ // The same holds for a code whose first syllable the user fixed.
+ assertEquals(
+ listOf("你好", "你"),
+ pipeline.preferExactNineKeyMatches("ni'426", listOf("你", "你好")),
+ )
+ // Nothing exact (or nothing else): Rime's order is left alone.
+ assertEquals(listOf("你好"), pipeline.preferExactNineKeyMatches("64", listOf("你好")))
+ assertEquals(listOf("你好", "你敢好"), pipeline.preferExactNineKeyMatches("9", listOf("你好", "你敢好")))
+ assertEquals(listOf("a", "b"), pipeline.preferExactNineKeyMatches(null, listOf("a", "b")))
+ }
}
diff --git a/app/src/test/java/llc/slacker/openime/IconLintTest.kt b/app/src/test/java/llc/slacker/openime/IconLintTest.kt
index e32d0469..3b05ab5a 100644
--- a/app/src/test/java/llc/slacker/openime/IconLintTest.kt
+++ b/app/src/test/java/llc/slacker/openime/IconLintTest.kt
@@ -24,12 +24,20 @@ class IconLintTest {
fun requireContains(value: String, message: String) {
if (!xml.contains(value)) violations += "${file.name}: $message"
}
- requireContains("""android:width="24dp"""", "width must be 24dp")
- requireContains("""android:height="24dp"""", "height must be 24dp")
+ // One coordinate canvas; the intrinsic size is one of the design's icon sizes.
requireContains("""android:viewportWidth="24"""", "viewportWidth must be 24")
requireContains("""android:viewportHeight="24"""", "viewportHeight must be 24")
+ val size = Regex("""android:width="(\d+)dp"""").find(xml)?.groupValues?.get(1)?.toInt()
+ if (size !in setOf(18, 20, 24) || !xml.contains("""android:height="${size}dp"""")) {
+ violations += "${file.name}: must be a square 18, 20 or 24dp icon"
+ }
+ // Outline icons share one stroke style; filled icons carry no stroke attributes at all.
if (xml.contains("android:strokeWidth=")) {
- violations += "${file.name}: strokeWidth is forbidden"
+ if (!xml.contains("""android:strokeWidth="1.8"""")) {
+ violations += "${file.name}: outline icons use strokeWidth 1.8"
+ }
+ requireContains("""android:strokeLineCap="round"""", "outline icons use round caps")
+ requireContains("""android:strokeLineJoin="round"""", "outline icons use round joins")
}
val colors = Regex("""android:(?:fillColor|strokeColor)="(#[0-9A-Fa-f]{8})"""")
.findAll(xml)
diff --git a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt
index 247a6fa4..36484c73 100644
--- a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt
+++ b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt
@@ -538,4 +538,134 @@ class InputConnectionGatewayTest {
gateway.deleteBackwards()
assertTrue(fake.events.any { it.startsWith("delete") })
}
+
+ /**
+ * An editor in the style of a Compose/custom/web field: it keeps real text
+ * and a cursor and answers before/after/selected-text queries, but offers
+ * no select-all action and no ExtractedText.
+ */
+ private class SurroundingOnlyEditor(
+ initial: String,
+ var selStart: Int,
+ var selEnd: Int,
+ private val answerCap: Int = Int.MAX_VALUE,
+ private val refuseDelete: Boolean = false,
+ ) : InputConnection {
+ val text = StringBuilder(initial)
+ private val lo get() = minOf(selStart, selEnd)
+ private val hi get() = maxOf(selStart, selEnd)
+
+ override fun getTextBeforeCursor(length: Int, flags: Int): CharSequence =
+ text.substring(0, lo).takeLast(minOf(length, answerCap))
+ override fun getTextAfterCursor(length: Int, flags: Int): CharSequence =
+ text.substring(hi).take(minOf(length, answerCap))
+ override fun getSelectedText(flags: Int): CharSequence? =
+ if (lo == hi) null else text.substring(lo, hi)
+ override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
+ val value = text?.toString().orEmpty()
+ this.text.replace(lo, hi, value)
+ val cursor = lo + value.length
+ selStart = cursor
+ selEnd = cursor
+ return true
+ }
+ override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
+ if (refuseDelete) return false
+ val start = lo
+ val end = hi
+ text.delete(end, (end + afterLength).coerceAtMost(text.length))
+ val from = (start - beforeLength).coerceAtLeast(0)
+ text.delete(from, start)
+ selStart = from
+ selEnd = from + (end - start)
+ return true
+ }
+ override fun setSelection(start: Int, end: Int): Boolean {
+ selStart = start
+ selEnd = end
+ return true
+ }
+ override fun performContextMenuAction(id: Int): Boolean = false
+ override fun getExtractedText(request: ExtractedTextRequest?, flags: Int): ExtractedText? = null
+
+ override fun beginBatchEdit(): Boolean = true
+ override fun endBatchEdit(): Boolean = true
+ override fun clearMetaKeyStates(states: Int): Boolean = false
+ override fun closeConnection() = Unit
+ override fun commitCompletion(text: CompletionInfo?): Boolean = false
+ override fun commitContent(inputContentInfo: InputContentInfo, flags: Int, opts: Bundle?): Boolean = false
+ override fun commitCorrection(correctionInfo: CorrectionInfo?): Boolean = false
+ override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean = false
+ override fun finishComposingText(): Boolean = true
+ override fun getCursorCapsMode(reqType: Int): Int = 0
+ override fun getHandler(): Handler? = null
+ override fun performEditorAction(editorAction: Int): Boolean = false
+ override fun performPrivateCommand(action: String?, data: Bundle?): Boolean = false
+ override fun reportFullscreenMode(monochrome: Boolean): Boolean = false
+ override fun requestCursorUpdates(cursorUpdateMode: Int): Boolean = false
+ override fun sendKeyEvent(event: KeyEvent?): Boolean = false
+ override fun setComposingRegion(start: Int, end: Int): Boolean = false
+ override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean = false
+ }
+
+ @Test
+ fun clearAllWorksInEditorsThatOnlyAnswerSurroundingTextQueries() {
+ val editor = SurroundingOnlyEditor("你好,世界", selStart = 2, selEnd = 2)
+ val gateway = InputConnectionGateway(null, { editor })
+
+ assertTrue(gateway.clearAllText())
+ assertEquals("", editor.text.toString())
+ assertTrue(gateway.hasClearUndo())
+
+ assertTrue(gateway.restoreLastClear())
+ assertEquals("你好,世界", editor.text.toString())
+ assertEquals(2, editor.selStart)
+ assertEquals(2, editor.selEnd)
+ }
+
+ @Test
+ fun surroundingTextClearKeepsAnExistingSelectionForUndo() {
+ val editor = SurroundingOnlyEditor("abcdef", selStart = 2, selEnd = 4)
+ val gateway = InputConnectionGateway(null, { editor })
+
+ assertTrue(gateway.clearAllText())
+ assertEquals("", editor.text.toString())
+
+ assertTrue(gateway.restoreLastClear())
+ assertEquals("abcdef", editor.text.toString())
+ assertEquals(2, editor.selStart)
+ assertEquals(4, editor.selEnd)
+ }
+
+ @Test
+ fun surroundingTextClearFinishesEditorsThatCapTheirAnswers() {
+ val editor = SurroundingOnlyEditor("0123456789", selStart = 5, selEnd = 5, answerCap = 3)
+ val gateway = InputConnectionGateway(null, { editor })
+
+ assertTrue(gateway.clearAllText())
+ assertEquals("", editor.text.toString())
+
+ assertTrue(gateway.restoreLastClear())
+ assertEquals("0123456789", editor.text.toString())
+ assertEquals(5, editor.selStart)
+ }
+
+ @Test
+ fun surroundingTextClearLosesNothingWhenTheEditorRefusesToDelete() {
+ val editor = SurroundingOnlyEditor("abcdef", selStart = 2, selEnd = 4, refuseDelete = true)
+ val gateway = InputConnectionGateway(null, { editor })
+
+ assertFalse(gateway.clearAllText())
+ assertEquals("abcdef", editor.text.toString())
+ assertFalse(gateway.hasClearUndo())
+ }
+
+ @Test
+ fun clearAllOnAnAlreadyEmptySurroundingTextEditorSucceedsWithoutUndo() {
+ val editor = SurroundingOnlyEditor("", selStart = 0, selEnd = 0)
+ val gateway = InputConnectionGateway(null, { editor })
+
+ assertTrue(gateway.clearAllText())
+ assertFalse(gateway.hasClearUndo())
+ }
}
diff --git a/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt b/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt
index 93e48e33..b12b0d5f 100644
--- a/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt
+++ b/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt
@@ -4,44 +4,59 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
+/**
+ * Metrics follow the reference design's 390-unit canvas: a 54dp portrait key
+ * row, rows that carry their own inner margin (so stacked rows add no extra
+ * gap), and a body that is the top zone plus four rows plus 16dp of padding.
+ */
class KeyboardLayoutMetricsTest {
@Test
- fun portraitUsesTouchTargetHeightAtDefaultFontScale() {
+ fun portraitUsesTheReferenceRowHeightAtDefaultFontScale() {
val metrics = KeyboardLayoutMetrics(landscape = false, fontScale = 1f)
- assertEquals(ImeGeometryTokens.TOUCH_TARGET_DP, metrics.keyRowHeightDp)
- assertTrue(metrics.imeHeightDp >= 302)
+ assertEquals(ImeGeometryTokens.KEY_ROW_HEIGHT_DP, metrics.keyRowHeightDp)
+ assertEquals(
+ ImeGeometryTokens.COMPOSED_TOP_ZONE_HEIGHT_DP + ImeGeometryTokens.KEY_ROW_HEIGHT_DP * 4 + 16,
+ metrics.imeHeightDp,
+ )
assertEquals(
metrics.imeHeightDp - metrics.topZoneHeightDp,
metrics.keyboardBodyHeightDp,
)
+ // A key row stays a comfortable touch target.
+ assertTrue(metrics.keyRowHeightDp >= ImeGeometryTokens.TOUCH_TARGET_DP)
}
@Test
fun landscapeUsesCompactBaseHeight() {
val metrics = KeyboardLayoutMetrics(landscape = true, fontScale = 1f)
assertEquals(ImeGeometryTokens.LANDSCAPE_KEY_ROW_HEIGHT_DP, metrics.keyRowHeightDp)
- assertTrue(metrics.imeHeightDp >= 264)
+ assertTrue(metrics.imeHeightDp >= 256)
}
@Test
fun largeFontGrowthIsBoundedToTwelveDp() {
val portrait = KeyboardLayoutMetrics(landscape = false, fontScale = 3f)
val landscape = KeyboardLayoutMetrics(landscape = true, fontScale = 3f)
- assertEquals(ImeGeometryTokens.TOUCH_TARGET_DP + 12, portrait.keyRowHeightDp)
+ assertEquals(ImeGeometryTokens.KEY_ROW_HEIGHT_DP + 12, portrait.keyRowHeightDp)
assertEquals(ImeGeometryTokens.LANDSCAPE_KEY_ROW_HEIGHT_DP + 12, landscape.keyRowHeightDp)
}
+ @Test
+ fun userHeightScaleStaysWithinTheSupportedRange() {
+ val smallest = KeyboardLayoutMetrics(landscape = false, fontScale = 1f, heightPercent = 10)
+ val largest = KeyboardLayoutMetrics(landscape = false, fontScale = 1f, heightPercent = 500)
+ // Out-of-range requests are clamped to 80%..120%, then floored at 44dp.
+ assertEquals(maxOf(44, ImeGeometryTokens.KEY_ROW_HEIGHT_DP * 80 / 100), smallest.keyRowHeightDp)
+ assertEquals(ImeGeometryTokens.KEY_ROW_HEIGHT_DP * 120 / 100, largest.keyRowHeightDp)
+ }
+
@Test
fun nineKeyDerivedHeightsRemainInternallyConsistent() {
val metrics = KeyboardLayoutMetrics(landscape = false, fontScale = 1.25f)
- assertEquals(
- metrics.keyRowHeightDp * 3 + ImeGeometryTokens.KEY_ROW_GAP_DP * 2,
- metrics.nineGridHeightDp,
- )
- assertEquals(
- metrics.nineGridHeightDp + ImeGeometryTokens.KEY_ROW_GAP_DP + metrics.keyRowHeightDp,
- metrics.nineBodyHeightDp,
- )
+ // Rows own their inner margin, so the grid is exactly three rows and the
+ // body exactly one more.
+ assertEquals(metrics.keyRowHeightDp * 3, metrics.nineGridHeightDp)
+ assertEquals(metrics.nineGridHeightDp + metrics.keyRowHeightDp, metrics.nineBodyHeightDp)
assertEquals(
metrics.keyRowHeightDp * 2 + ImeGeometryTokens.KEY_ROW_GAP_DP,
metrics.doubleKeyHeightDp,
diff --git a/app/src/test/java/llc/slacker/openime/NativeCandidatePipelineTest.kt b/app/src/test/java/llc/slacker/openime/NativeCandidatePipelineTest.kt
index 5abf2972..aceefa18 100644
--- a/app/src/test/java/llc/slacker/openime/NativeCandidatePipelineTest.kt
+++ b/app/src/test/java/llc/slacker/openime/NativeCandidatePipelineTest.kt
@@ -88,4 +88,13 @@ class NativeCandidatePipelineTest {
assertTrue(merged.isEmpty())
}
+
+ @Test
+ fun partialExtentSurvivesTheMergeSoCommitCanKeepTheRest() {
+ val merged = NativeCandidatePipeline.mergeRoundRobin(
+ listOf("64426" to listOf(RimeCandidateEntry("你好", 0, consumed = 5), RimeCandidateEntry("你", 1, consumed = 2))),
+ )
+ assertEquals(5, merged[0].reference.consumed)
+ assertEquals(2, merged[1].reference.consumed)
+ }
}
diff --git a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt
index bb606a29..3535f4fd 100644
--- a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt
+++ b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt
@@ -6,16 +6,19 @@ import org.junit.Test
/** Regression coverage for current-main production key geometry. */
class ProductionKeyPolicyTest {
@Test
- fun balancesTwentySixKeyBottomRowAroundSpace() {
+ fun twentySixKeyBottomRowFollowsTheReferenceCanvas() {
val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights()
- val left = weights.leftOuter + weights.leftInner
- val right = weights.rightInner + weights.rightOuter
- assertEquals(left, right, 0.0001f)
- assertEquals(1.60f, weights.leftOuter, 0.0001f)
- assertEquals(0.95f, weights.leftInner, 0.0001f)
- assertEquals(3.40f, weights.space, 0.0001f)
- assertEquals(1.05f, weights.rightInner, 0.0001f)
- assertEquals(1.50f, weights.rightOuter, 0.0001f)
+ // 123 | 中/英 | 空格 | (no right inner key) | 确定, in the design's 390 units.
+ assertEquals(68f, weights.leftOuter, 0.0001f)
+ assertEquals(56f, weights.leftInner, 0.0001f)
+ assertEquals(178f, weights.space, 0.0001f)
+ assertEquals(0f, weights.rightInner, 0.0001f)
+ assertEquals(88f, weights.rightOuter, 0.0001f)
+ assertEquals(
+ 390f,
+ weights.leftOuter + weights.leftInner + weights.space + weights.rightInner + weights.rightOuter,
+ 0.0001f,
+ )
}
@Test
diff --git a/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt b/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt
index b2b4f6b0..904ffcb2 100644
--- a/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt
+++ b/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt
@@ -2,6 +2,7 @@ package llc.slacker.openime
import java.io.File
import org.junit.Assert.assertFalse
+import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -40,9 +41,11 @@ class TokenDriftTest {
val light = source("app/src/main/res/values/colors.xml")
val dark = source("app/src/main/res/values-night/colors.xml")
+ // The setup pages sit on the toolbar surface (#EFF0F4) with white cards
+ // (toolCardBackground), exactly like the reference design's full pages.
mapOf(
- "setup_page_bg" to "#D5D8DE",
- "setup_surface" to "#EEF0F3",
+ "setup_page_bg" to "#EFF0F4",
+ "setup_surface" to "#FFFFFF",
"setup_title" to "#1C1C1E",
"setup_body" to "#6D6D72",
"setup_primary" to "#1D9BF0",
@@ -52,7 +55,7 @@ class TokenDriftTest {
}
mapOf(
"setup_page_bg" to "#1C1C1E",
- "setup_surface" to "#242426",
+ "setup_surface" to "#303032",
"setup_title" to "#F2F2F7",
"setup_body" to "#AEAEB2",
"setup_primary" to "#6EC3F7",
@@ -97,10 +100,15 @@ class TokenDriftTest {
}
.forEach { icon ->
val xml = icon.readText()
- assertTrue("${icon.name} width must be 24dp", xml.contains("android:width=\"24dp\""))
- assertTrue("${icon.name} height must be 24dp", xml.contains("android:height=\"24dp\""))
+ // One coordinate canvas for every icon, so paths are drawn on the same grid.
assertTrue("${icon.name} viewport width must be 24", xml.contains("android:viewportWidth=\"24\""))
assertTrue("${icon.name} viewport height must be 24", xml.contains("android:viewportHeight=\"24\""))
+ val width = Regex("""android:width="(\d+)dp"""").find(xml)?.groupValues?.get(1)?.toInt()
+ val height = Regex("""android:height="(\d+)dp"""").find(xml)?.groupValues?.get(1)?.toInt()
+ assertTrue("${icon.name} must declare a dp width and height", width != null && height != null)
+ assertEquals("${icon.name} must be square", width, height)
+ // Intrinsic sizes the reference design uses for icons.
+ assertTrue("${icon.name} size ${width}dp is not a design icon size", width in setOf(18, 20, 24))
}
}
diff --git a/docs/CHANGELOG_PRE_1.0.md b/docs/CHANGELOG_PRE_1.0.md
new file mode 100644
index 00000000..1f9de5ad
--- /dev/null
+++ b/docs/CHANGELOG_PRE_1.0.md
@@ -0,0 +1,36 @@
+# 1.0.0 之前的开发期记录
+
+这是 1.0.0 正式版之前的内部迭代记录,原样保留自旧版 `CHANGELOG.md`。从 1.0.0 起,
+`CHANGELOG.md` 只记录面向用户和维护者的版本变更;这里的条目已经概括进
+1.0.0 的发布说明,不再更新。
+
+## 开发期迭代(原 Unreleased)
+
+- 九键左栏改为按设计稿的「读法列表」:输入中整块面板、整条读法(`ni'hao`),长输入改列首音节;点选即锁定,锁定的音节继续打字时保持锁定,退格先解锁;预编辑边界统一为撇号。输入法的做法与取舍见 `docs/NINE_KEY_REFERENCE.md`。
+- 九键候选:数字刚好拼得出的词排在预测词之前;预编辑跟随首选词的读法,长句 `9694264244326` 得到 `wo'xiang'chi'fan` 与「我想吃饭」。
+- 选词只覆盖一部分输入时只上屏该词,剩余输入继续作为预编辑,不再丢失,也不再向用户词库写入没选过的整句。
+- 删除键上滑清空 / 下滑撤回:在自绘、Compose、Web 等没有「全选」也没有完整 ExtractedText 的输入框里也能清空和撤回(此前手势触发但文字纹丝不动);触发距离 56dp → 32dp;清空与撤回统一为同一个手势提示,清空后顶栏显示「已清空 · 撤销」。
+- 上屏后的联想栏改为「‹ 联想词 ∨」(此前收起箭头占一半行宽,联想词被挤到右边)。
+- 预编辑文字、工具图标等的强调色改为从当前强调色推导,不再写死 `#006AB1`,换强调色后不会出现两种蓝并排。
+- 修复键盘首次显示或系统取整出的宽度变化后整块按键为空白(行尺寸 0×0):`screenWidthDp` 的 float/int 取整差(约 0.0011)超过了 0.001 的比较容差,在 `onSizeChanged` 里重建按键行后没有再触发布局。容差改为 0.01,并在重建后补一次 `requestLayout`。
+- 设置页分段控件的每个选项触控高度由 34dp 提升到 48dp;轨道外观仍是 34dp,在主题里内缩绘制。
+- 同步设计稿改版后的测试契约:图标描边统一为 1.8、字号与颜色走 token、单选标记改为矢量图;插桩测试改为按无障碍名称查找语音语言控件,按手势阈值缩放后的 px 驱动空格滑动,并用 `isKeyPopupShown()` 判断按键预览(预览视图常驻,不能再靠子 View 数量判断)。
+- 修复九键方案未编译导致的 librime 静默降级:`luna_pinyin_simp` 与 `luna_pinyin_simp_fuzzy` 把字面代数规则写进了 `speller/algebra` 的 `__patch` 列表,librime 会把列表项当作补丁路径解析并报循环依赖,整个方案构建失败(部署日志 `4 success, 5 failure`),中文候选一直退回本地九键解码器。现改为在 `pinyin.yaml` 中定义命名的 `t9_transliteration` 节点并引用它。
+- `versionCode` 3 → 4:rime 数据部署标记随版本号变化,让已安装的设备重新拷贝修复后的 schema。
+- 持续完善输入法 UI、自适应布局和本地语音模型接入。
+- 引入固定版本的 Rime Ice 基础、扩展和 8105 字表,并增加首次部署期间即时可用的高频候选层。
+- 增强全拼、简拼、显式分词、首选命中和用户词排序,候选读取上限扩展到 96 项。
+- 统一选词、空格及回车提交后的 composition 清理,删除键不再误删残留候选状态。回车(确定)改为提交已输入的拼音原文,空格仍提交首选候选。
+- 增加连续长句、扩展候选、分词和选词后回删的真实 IME 回归脚本。
+- 建立 L0~L3 正式测试 SOP、统一证据目录、输入框实验室和隐私回归门禁。
+- 将 `DebugKeyboardActivity` 从主变体迁入 debug,release APK 不再导出测试宿主。
+- 将本地语音模型改为输入框出现时后台预热、隐藏后 10 秒热驻留并异步释放,键盘主线程不再执行模型哈希或映射。
+- 长按空格在模型预热期间先录音缓存,修复仅返回 final 时不上屏,并加入动态热词、本地语音纠错学习、音频路由隔离和无文本性能指标。
+- debug E2E 入口增加 `android.permission.DUMP` 保护,保留 adb 回归能力并阻止普通第三方 App 调用。
+
+## 最初的开发版本
+
+- 建立独立 `openIME` APK,包名为 `llc.slacker.openime`。
+- 接入 librime、OpenCC、中文拼音候选和多种键盘模式。
+- 接入 APK 内置的 sherpa-onnx 中英双语语音模型。
+- 建立真实 IME、生命周期、隐私和多宽度布局回归脚本。
diff --git a/docs/LICENSING.md b/docs/LICENSING.md
index 5a73bb59..b02bd2af 100644
--- a/docs/LICENSING.md
+++ b/docs/LICENSING.md
@@ -2,9 +2,12 @@
## 主项目
-`openIME` 当前尚未选择主项目许可证。仓库公开可见,但在添加明确许可证前,不能
-把“公开”理解为允许任意复制、再分发或商业使用。后续由项目所有者选择许可证后,
-应在仓库根目录增加标准 `LICENSE` 文件,并同步更新本页和 README。
+`openIME` 以 **GPL-3.0-only** 发布,全文见仓库根目录 `LICENSE`。
+
+选择它的原因:APK 内置的 Rime Ice 词典按 GPL-3.0-only 使用(见下),主项目采用同一份许可证,
+分发 APK 时整体的许可证状况没有歧义——不用争论词典数据与程序是「聚合」还是「衍生」。
+其余组件(librime、OpenCC、Snappy、sherpa-onnx、Paraformer 模型、Fluent Emoji)均为 BSD / Apache-2.0 / MIT,
+与 GPL-3.0 兼容。想改用别的许可证需要先去掉或替换 Rime Ice 词典。
## 已随仓库提供的第三方组件
diff --git a/docs/NINE_KEY_REFERENCE.md b/docs/NINE_KEY_REFERENCE.md
new file mode 100644
index 00000000..7f50892f
--- /dev/null
+++ b/docs/NINE_KEY_REFERENCE.md
@@ -0,0 +1,54 @@
+# 九键输入:参考实现与 openIME 的取舍
+
+这份文档记录「商业 / 开源输入法的九键到底怎么做」的调研结论,以及 openIME 据此做的决定。
+改九键、左栏、删除手势之前先读这里,避免回到「一个个补丁」的状态。
+
+## 调研来源
+
+| 来源 | 性质 | 采用的结论 |
+| -- | -- | -- |
+| [rime-t9-shiyin](https://github.com/Koishi-Neko/rime-t9-shiyin)(对标百度输入法小米版) | 开源,附引擎层实测报告 | 左栏逐音节选择:数字串 → 合法首音节(且剩余数字仍可切分);点选只锁定、不上屏;点完继续给下一位;全程「零上屏」;候选条只留词;删除键上滑清空 |
+| Trime 九键 / 仓输入法九键指南 | 开源 | 字母精确匹配、数字模糊匹配;`1` 键分词;锁定 / 解锁 / 撤销 |
+| [百度输入法九键说明](https://jingyan.baidu.com/article/19020a0a7ee4ab529c284246.html) | 官方使用说明 | 拼音键左侧是精确拼音;可上下滑动拼音列表更改拼音组成;按 1 手动分词 |
+| [搜狗输入法帮助](https://shouji.sogou.com/wap/feedback/faqdetail?id=2004148&click_fr=3&platform=Android) | 官方帮助 | 直接上滑删除键清空、直接下滑撤回;多次清空只保留最后一次 |
+| iOS 九宫格(「简体拼音十键」) | 系统输入法 | 拼音编码区 + 文字候选区分开;放弃单独的分词键 |
+| 《openIME 界面重构稿》设计稿 | 本项目设计依据 | 输入中左栏是整块面板,列整条读法(`ni'hao` / `mi'hao` / `ni'gao`),选中项为强调色胶囊;联想态为「‹ 词 … ∨」 |
+
+(豆包输入法、搜狗、微信键盘等闭源产品没有可读的实现,只能依据其公开使用说明。)
+
+## 行为约定(openIME 的实现)
+
+1. **点选即锁定,所见即所锁。** 左栏列出「读法」:短输入列整条读法(`ni'hao`),
+ 当整条读法放不下左栏时(> 14 个字符)改列首音节(`zhong`、`xiong`),锁定一个后列表移到下一位。
+ 不提供会让剩余数字无法拼读的选项;孤立的 `a/o/e`、无元音的 `ng/m` 不当读法。
+2. **锁定的音节发给 Rime 时保持字母**(`xiong'486`)。luna_pinyin 方案同时接受字母和 2–9 数字,
+ 所以 zhong / xiong 这类同数字的读法不会被重新混在一起,候选与所选读法一致。
+3. **锁定的音节在继续打字时保持锁定**(用边界封住),退格先解锁最近锁定的音节 / 分词边界。
+4. **预编辑里的音节边界一律是撇号**(`ni'hao`)。用户锁定的前缀以视图记录的为准,
+ 不能从文本里的空格 / 撇号反推(解码器自己也会插分隔,反推会把「猜测」当成「用户的决定」)。
+5. **数字刚好拼得出的词排在预测之前。** 输入 `9426`(xian)时 Rime 可能先给 `自从`(zi'cong 的预测);
+ 我们按读法是否恰好耗尽已输入数字分组,组内保持 Rime 顺序,不丢任何候选。
+6. **预编辑跟随首选词的读法**(`我想吃饭` → `wo'xiang'chi'fan`),而不是本地解码器独立的猜测。
+7. **选词只覆盖一部分输入时,只上屏该词,剩余输入继续作为预编辑**(九键 / 26 键一致)。
+ 本地不对部分选词做原生学习(Rime 的整句提交会把没选过的短语写进用户词库)。
+8. **空格提交首选词;回车(确定)提交已输入的拼音原文**(Rime / fcitx / Gboard 拼音的约定)。
+
+## 删除键手势
+
+- 上滑 ≥ 32dp 清空,下滑 ≥ 32dp 撤回(仅清空后 5 秒内);松手瞬间的坐标也算最后一次移动。
+- 反馈只有一个气泡组件:未到位时深色「上滑清空 / 下滑撤回」,到位后变色「松手清空(红)/ 松手撤回(强调色)」,
+ 放在键的侧边而不是上方(拇指会挡住上方)。清空后顶栏显示「已清空 · 撤销」。
+- 「清空全部」不能只依赖编辑器的全选动作:自绘 / Compose / Web 输入框通常既没有全选也没有完整的
+ ExtractedText。网关因此有第三条路径:只用光标前后文本,抓取全部内容、循环删除直到编辑器报告为空,
+ 并保留撤回快照。答案长度等于请求长度时可能只是窗口,一律拒绝删除。
+- 调试用 `CustomEditorTestActivity` 复现这类编辑器(修复前手势触发但文字纹丝不动,且无提示)。
+
+## 验证
+
+```bash
+bash scripts/core_regression.sh emulator-5554 # 26 键 / 九键 / 回车 / 部分选词
+./gradlew :app:testDebugUnitTest --tests '*CandidatePipeline*' --tests '*InputConnectionGateway*'
+```
+
+手机与模拟器的差异(密度、系统手势、默认输入法)会影响手势与布局;真机验证时,调试构建会把手势事件写到
+`OpenIme` 日志标签(`bs begin / clearArmed / finish`,被系统取消时有 `touch CANCEL`)。
diff --git a/docs/README.md b/docs/README.md
index d71a1d67..986a2b2b 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -9,12 +9,15 @@
- [MAPPING.md](MAPPING.md):Android 产品能力到真实实现的映射。
- [COORDINATE_SYSTEM.md](COORDINATE_SYSTEM.md):归一化坐标与窗口自适应规则。
- [REFERENCE_IME_GUIDE.md](REFERENCE_IME_GUIDE.md):参考输入法 UI 基线与取舍。
+- [NINE_KEY_REFERENCE.md](NINE_KEY_REFERENCE.md):九键 / 左栏 / 删除手势:商业与开源输入法的做法及 openIME 的取舍。
- [LOCAL_VOICE_MODEL.md](LOCAL_VOICE_MODEL.md):本地语音模型目录、校验和运行边界。
- [TEST_ARCHITECTURE.md](TEST_ARCHITECTURE.md):自动化层级、debug harness 和 CI 门禁。
- [TEST_SOP.md](TEST_SOP.md):L0~L3 正式测试流程。
- [TEST_SOP_CHECKLIST.md](TEST_SOP_CHECKLIST.md):多设备与人工交互验收清单。
- [LICENSING.md](LICENSING.md):主项目与第三方组件许可证边界。
-- [RELEASE.md](RELEASE.md):固定签名、arm64 正式包、标签发布、校验和与第三方清单。
+- [RELEASE.md](RELEASE.md):版本号规则(`VERSION`)、CHANGELOG、固定签名、arm64 正式包、标签发布、演练与回滚。
+- [REPOSITORY.md](REPOSITORY.md):分支、合并、`main` 与标签保护、安全与依赖更新,以及如何重新应用这些设置。
+- [CHANGELOG_PRE_1.0.md](CHANGELOG_PRE_1.0.md):1.0.0 之前的开发期记录(只读存档)。
旧的按 PR/分支推进的审计状态文档已删除。当前状态以 GitHub 分支/PR/CI 为准,长期执行顺序只维护在 `REPAIR_PLAN.md`,避免两份计划互相冲突。
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
index ab0cb24e..187cb7e1 100644
--- a/docs/RELEASE.md
+++ b/docs/RELEASE.md
@@ -1,52 +1,141 @@
-# 发布流程
+# 发布与版本管理
+
+仓库里的所有版本信息只有一个来源:根目录 `VERSION`。发布是一次可重复的流水线:
+发布 PR → 合并到 `main` → 在合并提交上打标签 → 工作流构建、校验、发布。
+仓库规则(分支保护、标签保护、合并方式)见 [REPOSITORY.md](REPOSITORY.md)。
+
+## 版本号
+
+- 语义化版本 `MAJOR.MINOR.PATCH`,只发布 `X.Y.Z` 稳定版,不使用 `-rc`、`-beta` 等后缀。
+- `VERSION` 是一行文本(例如 `1.0.0`)。`app/build.gradle.kts` 读取它:
+ `versionName = VERSION`,`versionCode = MAJOR × 10000 + MINOR × 100 + PATCH`
+ (`1.0.0` → `10000`,`1.2.3` → `10203`)。要求 `MAJOR ≥ 1`,`MINOR`、`PATCH` 不超过 99。
+ 这样 `versionCode` 不会被忘记升级,也永远严格递增。
+- `scripts/release_check.py` 用同一公式检查仓库,并用 `aapt2` 校验构建出的 APK 里的
+ 包名、`versionName`、`versionCode`;PR 的 CI 和发布工作流都会运行它。
+
+什么时候升哪一位:
+
+| 升级 | 条件 |
+|---|---|
+| MAJOR | 用户数据格式不兼容或需要用户手动迁移;`minSdk` 提高;包名或签名变化 |
+| MINOR | 新功能、新面板或键盘;词库、语音模型、第三方 runtime 的版本变化(需重新核对许可证) |
+| PATCH | 缺陷修复、性能、文案、依赖的安全更新 |
+
+Rime 共享数据以 `versionCode` 作为部署标记:每次升级后首次启动都会重新部署共享词典
+(用户词库在独立目录,不受影响)。所以 PATCH 版本也会触发一次重新部署。
+
+只维护最新的一条 MINOR 版本线;安全修复以 PATCH 版本发布。
+
+## CHANGELOG
+
+[CHANGELOG.md](../CHANGELOG.md) 遵循 Keep a Changelog:
+
+- 日常 PR 把用户可见的改动写进 `## [Unreleased]`,**不改 `VERSION`**。
+- 最新的 `## [X.Y.Z] - YYYY-MM-DD` 小节必须正好等于 `VERSION`,写法上不允许空小节、
+ 版本或日期倒序。`release_check.py check` 在 CI 里强制这些规则,所以版本号与
+ 变更记录只能一起变化。
+- 这一小节的正文就是 GitHub Release 的发布说明,请按用户能读懂的方式写。
+- 已撤回的版本在标题后加 ` [YANKED]`,并发布更高的 PATCH 版本。
## 发布产物
-正式发布只生成 `arm64-v8a` APK,文件名为:
+只生成 `arm64-v8a` 的正式 APK:
```text
-openIME-v{versionName}-arm64-release.apk
+openIME-v{VERSION}-arm64-release.apk
```
-同一 GitHub Release 同时上传:
+同一个 GitHub Release 还有 `SHA256SUMS.txt` 和 `THIRD_PARTY_NOTICES.release.md`。
+Debug 构建保留 `arm64-v8a + x86_64`,只用于真机和模拟器回归,不发布。
-- `SHA256SUMS.txt`
-- `THIRD_PARTY_NOTICES.release.md`
+## 签名密钥
-Debug 构建继续保留 `arm64-v8a + x86_64`,用于真机与模拟器回归。
+密钥是应用的永久身份:Android 只在新 APK 与已安装版本由同一把密钥签名时才允许覆盖安装,
+密钥丢了就只能让所有用户卸载重装。keystore 不进入仓库、Issue、PR、Actions artifact 或 Release,
+也不要从 Debug keystore 发布。
-## 固定发布签名
+一次性初始化(在你信任的机器上运行,不要让别人代跑):
-GitHub Actions 需要以下四个 repository secrets:
+```bash
+bash scripts/setup_release_signing.sh
+```
-- `OPENIME_KEYSTORE_B64`:发布 keystore 的 Base64 内容。
-- `OPENIME_KEYSTORE_PASSWORD`
-- `OPENIME_KEY_ALIAS`
-- `OPENIME_KEY_PASSWORD`
+脚本会:生成 4096 位 RSA keystore(默认放在 `~/.openime-release/`)、随机口令、
+并把 `release.yml` 读取的四个 Actions secrets 写进仓库:
-本地 release 构建也可使用同名环境变量;其中 `OPENIME_KEYSTORE_PATH` 指向本机 keystore 文件,不使用 Base64。
+- `OPENIME_KEYSTORE_B64`:keystore 的 Base64。
+- `OPENIME_KEYSTORE_PASSWORD`、`OPENIME_KEY_ALIAS`、`OPENIME_KEY_PASSWORD`。
-keystore 不进入仓库,不提交到 Issue、PR、Actions artifact 或 Release。
+口令不会打印。运行后**立刻备份** `~/.openime-release/`:离线加密副本加密码管理器,
+`credentials.txt` 里是明文口令,放进密码管理器后不要留在普通云盘。
-## 发布步骤
+首次发布后,把证书 SHA-256 写入 [`release-cert.sha256`](release-cert.sha256)(发布说明里就有)。
+之后每次发布都会比对它:签名证书对不上就直接失败,避免换了密钥却没发现。
-1. 更新 `app/build.gradle.kts` 的 `versionCode` 和 `versionName`。
-2. 在目标提交上完成 JVM 单测、Lint、Debug 构建和设备回归。
-3. 创建与 `versionName` 完全一致的标签,例如 `versionName = "1.0.3"` 对应 `v1.0.3`。
-4. 推送标签。
-5. `.github/workflows/release.yml` 自动执行单测、`lintRelease`、`assembleRelease`、APK 签名校验和 ABI 校验。
-6. 工作流生成 SHA-256,并创建 GitHub Release。
+本地也可以用自己的 keystore 构建 release:设置同名环境变量(`OPENIME_KEYSTORE_PATH` 指向文件,
+不用 Base64),运行 `scripts/release_build.sh`。
-标签与 `versionName` 不一致、签名 secrets 缺失、APK 未签名或包含非 `arm64-v8a` native ABI 时,发布任务会失败。
+## 发布步骤
-## 社交预览
+1. `main` 上最近一次 CI 全绿(包括 API 29 / 31 兼容测试)。
+2. 发布 PR:把 `[Unreleased]` 整理成 `## [X.Y.Z] - YYYY-MM-DD`,同时修改 `VERSION`。
+ 本地先运行:
+
+ ```bash
+ python3 scripts/release_check.py check
+ ```
+
+ 这个 PR 改到了发布相关文件,CI 会自动用一次性密钥完整演练一遍发布流水线(见下)。
+3. 合并后,在 `main` 的合并提交上打带注释的标签并推送:
+
+ ```bash
+ git switch main && git pull
+ git tag -a vX.Y.Z -m "openIME X.Y.Z"
+ git push origin vX.Y.Z
+ ```
+
+ 标签只有管理员能创建,创建后不能被移动或删除(见 REPOSITORY.md)。
+4. `.github/workflows/release.yml` 自动执行:
+ - 标签格式、`VERSION`、`CHANGELOG.md` 三者一致;标签在 `main` 上,且该提交的 CI 已通过;
+ - 单元测试、`lintRelease`、`assembleRelease`;
+ - APK 签名校验(不能是 Debug 证书)、只含 `arm64-v8a`、APK 内版本与 `VERSION` 一致、
+ 签名证书与 `release-cert.sha256` 一致;
+ - 生成 SHA-256 和发布说明;
+ - 另一个只有写权限、不接触密钥的 job 先建**草稿** Release,确认三个附件齐全后才公开。
+5. 发布后核对:下载 APK,`sha256sum -c SHA256SUMS.txt`,`apksigner verify --print-certs`,
+ 在真机上安装、启用、试打。
+
+### 演练
+
+`Android Release` 工作流在两种情况下用一把只存在于该次运行的一次性密钥完整执行同一套构建和校验,
+但不发布任何东西:手动触发(Actions → Android Release → Run workflow),以及 PR 改动了
+`release.yml`、`release_build.sh`、`release_check.py`、`VERSION`、`CHANGELOG.md` 或 `app/build.gradle.kts`。
+所以发布流水线在真正发布之前就已经跑过。本地同样可以演练:
+
+```bash
+OPENIME_REHEARSAL=1 OPENIME_SKIP_TESTS=1 scripts/release_build.sh # 需要上面的四个环境变量
+```
-仓库内提供 `docs/images/social-preview.png`(1280×640),由 `scripts/generate_brand_assets.py` 生成。GitHub 的仓库 Social preview 不是源码文件配置项,因此需要仓库管理员在 **Settings → General → Social preview** 上传该 PNG;这一步不能通过当前仓库代码提交自动完成。
+### 失败与回滚
+
+- 发布工作流在发布前失败:修复后通过 PR 合并,管理员删除远端标签再重新打在新的提交上
+ (`git push origin :refs/tags/vX.Y.Z`);如果留下了草稿 Release,先把它删掉。
+- 已公开的版本发现问题:Android 不允许降级,不要删除或改写标签。在 CHANGELOG 里给该版本标
+ `[YANKED]`,把 Release 改成 pre-release 并写明原因,然后发布更高的 PATCH 版本。
+- 密钥泄露:立即停止发布,在 Settings 里删除 secrets,更换密钥会让所有现有用户必须卸载重装,
+ 需要先在发布说明里写清楚迁移步骤(导出用户数据 → 卸载 → 安装 → 导入)。
## 发布前检查
- `AndroidManifest.xml` 不含 `INTERNET` 权限。
- `android:allowBackup="false"` 保持不变。
- `THIRD_PARTY_NOTICES.md` 与 `app/src/main/assets/licenses/` 同步。
-- 语音模型、词库或第三方 runtime 版本变化时重新核对对应许可证。
-- 不从 Debug keystore 发布。
+- 语音模型、词库或第三方 runtime 版本变化时重新核对对应许可证(见 [LICENSING.md](LICENSING.md))。
+- 主项目许可证:`LICENSE`(GPL-3.0-only),说明见 LICENSING.md。
+
+## 社交预览
+
+仓库内提供 `docs/images/social-preview.png`(1280×640),由 `scripts/generate_brand_assets.py` 生成。
+GitHub 的 Social preview 不是源码文件配置项,需要仓库管理员在 **Settings → General → Social preview**
+上传该 PNG;这一步不能通过提交代码完成。
diff --git a/docs/REPOSITORY.md b/docs/REPOSITORY.md
new file mode 100644
index 00000000..44096eef
--- /dev/null
+++ b/docs/REPOSITORY.md
@@ -0,0 +1,68 @@
+# 仓库管理
+
+这里记录 GitHub 仓库的治理规则。它们由 [`scripts/apply_repo_settings.sh`](../scripts/apply_repo_settings.sh)
+应用,所以改规则就是改这个脚本和本文档,再由管理员重新运行;不要只在网页上点选。
+
+## 分支模型
+
+- 主干开发:`main` 始终可发布。从最新的 `main` 开短生命周期分支,命名 `类型/主题`,
+ 类型取 `feat`、`fix`、`docs`、`chore`、`ci`、`refactor`、`test`,例如 `fix/pinyin-candidate`。
+- 需要维护旧版本线时才从旧标签建 `release/X.Y` 分支;日常热修复直接在 `main` 上发布 PATCH。
+- 合并后分支自动删除。超过 30 天没有提交也没有 PR 的分支,维护者每月清理一次。
+
+## 合并
+
+- 只允许 **squash 合并**;不允许 merge commit 和 rebase merge,历史保持线性。
+- squash 提交信息取 **PR 标题 + PR 描述**,不再拼接分支里的每个提交说明。所以 PR 标题要独立说清结果
+ (推荐 Conventional Commits 前缀:`feat:`、`fix:`、`docs:`、`ci:`…),描述写目的、影响、验证和已知限制。
+- 提交说明、PR 和讨论可以用中文或英文,同一个 PR 内保持一致。
+
+## `main` 的保护规则
+
+- 必须通过 PR 合并;必须通过检查 **Build and verify**(单元测试、Lint、构建、版本与变更记录检查)。
+- 禁止 force push、禁止删除、要求线性历史、要求解决所有评审对话。
+- 不强制管理员遵守(`enforce_admins=false`):所有者在 `main` 出问题时仍能直接修复。
+ 这是应急通道,不是日常做法。
+- 不要求他人批准(单人维护);有第二位维护者后,把 `required_approving_review_count` 调到 1,
+ 并在 `.github/CODEOWNERS` 里加人。
+- API 29 / 31 兼容测试在每个 PR 上运行并真实失败(测试不通过就红),但不阻止合并,
+ 避免模拟器偶发问题卡住发布。想强制时把 `Compatibility API 29`、`Compatibility API 31` 加进
+ `apply_repo_settings.sh` 的 `contexts`。发布工作流本身要求这三项检查都通过。
+- 如果重命名了 `android.yml` 里的 job,同步修改 `contexts`,否则 PR 会一直等待一个不存在的检查。
+
+## 标签
+
+规则集 `release-tags` 作用于 `v*`:只有仓库管理员能创建,创建后任何人(管理员除外)都不能移动或删除。
+发布标签格式固定为 `vX.Y.Z`,必须与 `VERSION` 一致,详见 [RELEASE.md](RELEASE.md)。
+
+## 安全
+
+- Secret scanning 与 push protection 开启;Dependabot 告警与安全更新开启。
+- 私密漏洞报告开启(Security → Report a vulnerability),说明见 [SECURITY.md](../SECURITY.md)。
+- Actions 默认令牌只读(`default_workflow_permissions=read`),Actions 不能批准 PR。
+ 需要写权限的 job 在工作流里单独声明;签名密钥只在 `release.yml` 的构建 job 里读取,
+ 该 job 没有写权限,发布 job 有写权限但接触不到密钥。
+- 签名 secrets 的建立和备份见 RELEASE.md。keystore 不进入仓库,`.gitignore` 也会拦截 `*.jks`、`*.keystore`、`*.p12`。
+
+## 依赖
+
+`.github/dependabot.yml` 每周一检查 GitHub Actions 和 Gradle 依赖,次要和补丁更新合并成一个 PR;
+Gradle 的主版本(AGP、Kotlin 等)与 NDK、SDK 的固定版本绑定,由人工升级。
+固定提交的资源不会被自动更新:Rime Ice 词典(见 `THIRD_PARTY_NOTICES.md`)、librime 依赖
+(`scripts/fetch_rime_deps.sh`)、sherpa-onnx AAR 和语音模型(Git LFS)。
+
+## 大文件与仓库卫生
+
+- 大二进制(`*.onnx`、`*.aar`)走 Git LFS;不要提交 APK(`.gitignore` 已拦截)、截图、UI dump、设备日志。
+- `output/` 是设计参考脚本的本地产物,已忽略。
+- Debug APK 约 380 MB,超过 GitHub 单文件 100 MB 限制;需要测试包时从 PR 的 CI artifact
+ `openIME-test-apks` 下载(保留 1 天)。
+
+## 重新应用设置
+
+```bash
+bash scripts/apply_repo_settings.sh --dry-run # 只打印请求
+bash scripts/apply_repo_settings.sh # 应用,需要仓库管理员权限和已登录的 gh
+```
+
+仓库没有 Wiki(文档都在 `docs/`),已关闭。Social preview 图片只能在网页上传,见 RELEASE.md。
diff --git a/docs/TEST_ARCHITECTURE.md b/docs/TEST_ARCHITECTURE.md
index 60ea9966..5ce46a33 100644
--- a/docs/TEST_ARCHITECTURE.md
+++ b/docs/TEST_ARCHITECTURE.md
@@ -64,11 +64,20 @@ GitHub Actions 当前兼容矩阵运行 API 29 和 API 31。更高 API 和真机
`.github/workflows/android.yml` 当前执行:
-1. `:app:testDebugUnitTest`
-2. `:app:lintDebug`
-3. `:app:assembleDebug`
-4. API 29 `:app:connectedDebugAndroidTest`
-5. API 31 `:app:connectedDebugAndroidTest`
+1. 版本与变更记录检查:`scripts/test_release_check.py`、`scripts/release_check.py check`
+2. `:app:testDebugUnitTest`
+3. `:app:lintDebug`
+4. `:app:assembleDebug`、`:app:assembleDebugAndroidTest`,并校验 APK 内的包名与版本
+5. API 29 全部仪器测试
+6. API 31 全部仪器测试
+
+`am instrument` 即使有测试失败也以 0 退出,所以兼容矩阵会检查输出末尾的 `OK (N tests)`,
+否则让 job 失败;报告保存在 `compatibility-api-*` artifact 里。
+
+`main` 要求 **Build and verify** 通过才能合并;兼容矩阵在 PR 上同样运行并会真实变红,
+但不阻止合并(原因与如何改成必须通过见 [REPOSITORY.md](REPOSITORY.md))。
+发布工作流额外要求这三项检查都通过,并在 PR 改到发布流水线时用一次性密钥演练
+`lintRelease` + `assembleRelease`(见 [RELEASE.md](RELEASE.md))。
PR 或分支上的“最新 HEAD”必须对应最新 CI;旧 SHA 的绿色结果不能证明新提交通过。
diff --git a/docs/TEST_SOP.md b/docs/TEST_SOP.md
index 3e05c71a..530df926 100644
--- a/docs/TEST_SOP.md
+++ b/docs/TEST_SOP.md
@@ -139,8 +139,11 @@ OpenIME
删除顺序固定为:composition → 候选状态 → 目标编辑器文本。Emoji 必须按完整字素删除,
长按松手立即停止,空状态继续删除不能崩溃。
-上滑清空必须满足:超过阈值才进入;红色背景和白色“清空”;进入时轻反馈;滑回/侧滑取消;
-松手才执行一次;清空后正文、拼音、候选一致。
+上滑清空必须满足:上滑 ≥ 32dp 才进入(未到位是深色「上滑清空」提示,进入后变红色「松手清空」);
+进入时轻反馈;滑回/侧滑取消;松手才执行一次;清空后正文、拼音、候选一致,顶栏显示「已清空 · 撤销」。
+下滑 ≥ 32dp 撤回,仅在清空后 5 秒内可用(提示为「下滑撤回」→ 强调色「松手撤回」)。
+两者必须在没有「全选」也没有完整 ExtractedText 的输入框(自绘 / Compose / Web,可用调试
+`CustomEditorTestActivity` 复现)里同样生效。
## 9. 空格、语音、气泡和震动
diff --git a/docs/release-cert.sha256 b/docs/release-cert.sha256
new file mode 100644
index 00000000..4eb25b6e
--- /dev/null
+++ b/docs/release-cert.sha256
@@ -0,0 +1 @@
+unset
diff --git a/scripts/README.md b/scripts/README.md
index 5cc60b3d..dbd3f3be 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -3,6 +3,18 @@
所有脚本都从仓库根目录解析 APK 和包名。它们不会依赖固定屏幕坐标;需要设备的
脚本必须显式传入 `-Serial`,或者设置 `ANDROID_SERIAL`。
+## 发布与仓库管理
+
+这些脚本不需要设备:
+
+```bash
+python3 scripts/release_check.py check # VERSION 与 CHANGELOG.md 一致(CI 也会运行)
+python3 -m unittest discover -s scripts -p 'test_*.py'
+bash scripts/release_build.sh # 构建并校验已签名的 arm64 release(需要签名环境变量,见 docs/RELEASE.md)
+bash scripts/setup_release_signing.sh # 一次性:生成发布密钥并写入 Actions secrets
+bash scripts/apply_repo_settings.sh --dry-run # 查看将要应用的仓库规则(见 docs/REPOSITORY.md)
+```
+
## 常用命令
```powershell
diff --git a/scripts/apply_repo_settings.sh b/scripts/apply_repo_settings.sh
new file mode 100755
index 00000000..d6dde70e
--- /dev/null
+++ b/scripts/apply_repo_settings.sh
@@ -0,0 +1,114 @@
+#!/usr/bin/env bash
+# Apply the GitHub repository settings described in docs/REPOSITORY.md, so they
+# are reviewable in git and can be re-applied if someone changes them by hand.
+# Idempotent. Needs admin rights on the repository and an authenticated `gh`.
+#
+# bash scripts/apply_repo_settings.sh [--repo OWNER/NAME] [--dry-run]
+#
+# --dry-run prints every request body without sending anything.
+set -euo pipefail
+
+REPO=""
+DRY=0
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --repo) REPO="$2"; shift 2 ;;
+ --dry-run) DRY=1; shift ;;
+ -h|--help) sed -n '2,9p' "$0"; exit 0 ;;
+ *) echo "unknown argument: $1" >&2; exit 2 ;;
+ esac
+done
+
+command -v gh >/dev/null || { echo "gh (GitHub CLI) not found" >&2; exit 1; }
+[[ -n "$REPO" ]] || REPO="$(gh repo view --json nameWithOwner --jq .nameWithOwner)"
+echo "Repository: $REPO$([[ "$DRY" == "1" ]] && echo ' (dry run)')"
+
+send() { # send METHOD PATH [JSON] (PATH is relative to repos/OWNER/NAME; empty for the repo itself)
+ local method="$1" path="$2" body="${3:-}"
+ local url="repos/$REPO${path:+/$path}"
+ echo "-> $method $url"
+ if [[ "$DRY" == "1" ]]; then
+ [[ -z "$body" ]] || printf '%s\n' "$body"
+ return 0
+ fi
+ if [[ -n "$body" ]]; then
+ printf '%s' "$body" | gh api -X "$method" "$url" --input - >/dev/null
+ else
+ gh api -X "$method" "$url" >/dev/null
+ fi
+}
+
+# 1. Merge policy: squash only, PR title and body become the commit message
+# (the default concatenates every commit message), branches clean up after merge.
+send PATCH "" '{
+ "description": "本地优先的 Android 中文拼音输入法:librime 拼音 + 内置离线语音识别,不声明 INTERNET 权限",
+ "has_wiki": false,
+ "allow_squash_merge": true,
+ "allow_merge_commit": false,
+ "allow_rebase_merge": false,
+ "allow_auto_merge": false,
+ "allow_update_branch": true,
+ "delete_branch_on_merge": true,
+ "squash_merge_commit_title": "PR_TITLE",
+ "squash_merge_commit_message": "PR_BODY",
+ "security_and_analysis": {
+ "secret_scanning": {"status": "enabled"},
+ "secret_scanning_push_protection": {"status": "enabled"}
+ }
+}'
+
+send PUT "topics" '{"names": ["android", "ime", "input-method", "pinyin", "rime", "librime", "chinese", "offline", "speech-recognition", "kotlin"]}'
+
+# 2. main: changes arrive through a pull request whose "Build and verify" check
+# passed; no force pushes, no deletion, linear history. Admins are not
+# locked out (enforce_admins=false) so the owner can still fix a broken main.
+# The API-29/31 compatibility jobs report on every PR but do not block: an
+# emulator hiccup should not stop a merge. Add them to "contexts" to enforce.
+send PUT "branches/main/protection" '{
+ "required_status_checks": {"strict": false, "contexts": ["Build and verify"]},
+ "enforce_admins": false,
+ "required_pull_request_reviews": {
+ "required_approving_review_count": 0,
+ "dismiss_stale_reviews": true,
+ "require_code_owner_reviews": false
+ },
+ "restrictions": null,
+ "required_linear_history": true,
+ "allow_force_pushes": false,
+ "allow_deletions": false,
+ "required_conversation_resolution": true,
+ "allow_fork_syncing": true
+}'
+
+# 3. Release tags are created by an admin only and cannot be moved or deleted
+# by anyone else. (Repository admins can bypass, so a mistaken tag can still be fixed.)
+RULESET='{
+ "name": "release-tags",
+ "target": "tag",
+ "enforcement": "active",
+ "conditions": {"ref_name": {"include": ["refs/tags/v*"], "exclude": []}},
+ "rules": [{"type": "creation"}, {"type": "update"}, {"type": "deletion"}, {"type": "non_fast_forward"}],
+ "bypass_actors": [{"actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always"}]
+}'
+RULESET_ID="$(gh api "repos/$REPO/rulesets" --jq '.[] | select(.name == "release-tags") | .id' 2>/dev/null | head -n 1 || true)"
+if [[ -n "$RULESET_ID" ]]; then
+ send PUT "rulesets/$RULESET_ID" "$RULESET"
+else
+ send POST "rulesets" "$RULESET"
+fi
+
+# 4. Security features that cost nothing on a public repository.
+send PUT "vulnerability-alerts"
+send PUT "automated-security-fixes"
+send PUT "private-vulnerability-reporting"
+send PUT "actions/permissions/workflow" '{"default_workflow_permissions": "read", "can_approve_pull_request_reviews": false}'
+
+# 5. Labels the Dependabot configuration refers to.
+if [[ "$DRY" == "1" ]]; then
+ echo "-> gh label create dependencies / ci (--force)"
+else
+ gh label create dependencies --repo "$REPO" --color 0366d6 --description "Dependency updates" --force >/dev/null
+ gh label create ci --repo "$REPO" --color ededed --description "CI and release pipeline" --force >/dev/null
+fi
+
+echo "Done."
diff --git a/scripts/core_regression.ps1 b/scripts/core_regression.ps1
index e80f624c..df9e1101 100644
--- a/scripts/core_regression.ps1
+++ b/scripts/core_regression.ps1
@@ -165,7 +165,7 @@ Mode 'PINYIN_9'
# the visible pre-edit stays as Pinyin and the user can tap a candidate
# without inserting a numeric string into the target editor.
foreach ($key in @('6', '4', '4', '2', '6')) { Tap $key }
-Tap '确定'
+Tap 'key-space'
AssertText '030 9-key continuous buffer -> 你好' '你好' (GetText 'crp9')
StartReal
diff --git a/scripts/core_regression.sh b/scripts/core_regression.sh
index 5e9e5cba..b8c9fd48 100755
--- a/scripts/core_regression.sh
+++ b/scripts/core_regression.sh
@@ -186,11 +186,39 @@ mode ENGLISH_26 || say 'WARN: ENGLISH_26 not reached'
for c in o p e n i m e; do tap "$c"; done
check '025 English 26 path -> openime' 'openime' "$(editor_text)"
+start_real
+mode PINYIN_9 || say 'WARN: PINYIN_9 not reached'
+for key in 6 4 4 2 6; do tap "$key"; done
+tap key-space
+check '030 nine-key 64426 + space -> 你好' '你好' "$(editor_text)"
+
start_real
mode PINYIN_9 || say 'WARN: PINYIN_9 not reached'
for key in 6 4 4 2 6; do tap "$key"; done
tap '确定'
-check '030 nine-key 64426 -> 你好' '你好' "$(editor_text)"
+check '031 nine-key 64426 + enter keeps pinyin -> nihao' 'nihao' "$(editor_text)"
+
+start_real
+mode PINYIN_26 || say 'WARN: PINYIN_26 not reached'
+for key in n i h a o; do tap "$key"; done
+tap key-enter
+check '032 26-key nihao + enter keeps pinyin -> nihao' 'nihao' "$(editor_text)"
+
+start_real
+mode PINYIN_9 || say 'WARN: PINYIN_9 not reached'
+for key in 6 4 4 2 6 6 2; do tap "$key"; done
+tap '候选:你'
+check "033 nine-key partial pick keeps the rest composing -> 你hao'ma" "你hao'ma" "$(editor_text)"
+tap key-space
+check '034 nine-key rest then space -> 你好吗' '你好吗' "$(editor_text)"
+
+start_real
+mode PINYIN_26 || say 'WARN: PINYIN_26 not reached'
+for key in n i h a o m a; do tap "$key"; done
+tap '候选:你'
+check '035 26-key partial pick keeps the rest composing -> 你haoma' '你haoma' "$(editor_text)"
+tap key-space
+check '036 26-key rest then space -> 你好吗' '你好吗' "$(editor_text)"
start_real
mode DIGITS || say 'WARN: DIGITS not reached'
diff --git a/scripts/design_reference_e2e.py b/scripts/design_reference_e2e.py
new file mode 100644
index 00000000..4e85065e
--- /dev/null
+++ b/scripts/design_reference_e2e.py
@@ -0,0 +1,301 @@
+#!/usr/bin/env python3
+"""Real IME visual/interaction E2E. Explicit emulator serial; screenshots + replay log.
+Usage: python3 scripts/design_reference_e2e.py emulator-5554 [output-directory]
+Append --panels-only after the output directory to refresh only affected panels.
+Requires a built/installed debug APK and Pillow for cropped reference comparisons.
+Backs up and restores affected preferences; uses deterministic visual fixtures.
+Restores emulator display, locale and font scale. No unit tests or fake InputConnection.
+"""
+import base64
+import json
+import re
+import subprocess
+import sys
+import time
+from pathlib import Path
+import xml.etree.ElementTree as ET
+from PIL import Image
+
+serial = sys.argv[1] if len(sys.argv) > 1 else ''
+if not serial.startswith('emulator-'):
+ raise SystemExit('Please pass an explicit emulator serial; this suite changes display configuration.')
+out = Path(sys.argv[2] if len(sys.argv) > 2 else 'output/design-reference').resolve()
+out.mkdir(parents=True, exist_ok=True)
+pkg = 'llc.slacker.openime'
+panels_only = '--panels-only' in sys.argv
+records = json.loads((out / 'results.json').read_text()) if panels_only and (out / 'results.json').exists() else []
+
+def adb(*args, binary=False):
+ result = subprocess.run(['adb', '-s', serial, *args], check=True, capture_output=True)
+ return result.stdout if binary else result.stdout.decode('utf-8', errors='replace').strip()
+
+def command(value):
+ adb('logcat', '-c')
+ adb('shell', 'am', 'broadcast', '-n', pkg + '/.E2ETestReceiver', '-a', pkg + '.TEST_COMMAND', '--es', 'cmd', value)
+ time.sleep(0.3)
+ log = adb('logcat', '-d', '-s', 'OpenImeE2E:I', 'OpenIme:I')
+ if 'ok=true' not in log:
+ raise AssertionError(f'Command failed: {value}\n{log}')
+ return log
+
+def tap(value):
+ return command('tap:' + value)
+
+def tree():
+ adb('shell', 'uiautomator', 'dump', '/sdcard/openime-design.xml')
+ return adb('shell', 'cat', '/sdcard/openime-design.xml')
+
+def editor():
+ for node in ET.fromstring(tree()).iter('node'):
+ if node.get('resource-id') == pkg + ':id/test_input':
+ return node.get('text', '') if node.get('text', '') != node.get('hint') else ''
+ raise AssertionError('Real editor missing')
+
+def launch():
+ adb('shell', 'am', 'force-stop', pkg)
+ adb('shell', 'am', 'start', '-n', pkg + '/.MainActivity')
+ time.sleep(1)
+ for attempt in range(5):
+ for node in ET.fromstring(tree()).iter('node'):
+ if node.get('resource-id') == pkg + ':id/test_input':
+ x1, y1, x2, y2 = map(int, re.findall(r'\d+', node.get('bounds')))
+ if y2 <= y1: continue
+ adb('shell', 'input', 'tap', str((x1+x2)//2), str((y1+y2)//2))
+ time.sleep(1)
+ for _ in range(5):
+ if 'window=' in command('bounds'):
+ command('state')
+ return
+ adb('shell', 'input', 'tap', str((x1+x2)//2), str((y1+y2)//2))
+ time.sleep(.5)
+ sizes = re.findall(r'(\d+)x(\d+)', adb('shell', 'wm', 'size'))
+ w, h = map(int, sizes[-1])
+ adb('shell', 'input', 'swipe', str(w//2), str(int(h*.6)), str(w//2), str(int(h*.3)), '250')
+ raise AssertionError('Cannot focus real editor')
+
+def capture(name, required=()):
+ time.sleep(0.3)
+ log = command('bounds')
+ match = re.search(r'window=(\d+),(\d+),(\d+),(\d+)', log)
+ if not match:
+ raise AssertionError('No measured real IME window')
+ x, y, w, h = map(int, match.groups())
+ for label in required:
+ if label not in log:
+ raise AssertionError(f'{name}: missing {label}')
+ # Every actual keyboard key must fit its measured window. Scrollable panel
+ # content may intentionally extend beyond its viewport and is excluded.
+ for tag, bounds in re.findall(r'tag=(key:[^|]+|key-9:[^|]+|key-enter|key-space)\|[^\n]*?\|([\d.eE,+-]+)', log):
+ left, top, width, height = map(float, bounds.split(','))
+ scrolling_symbols = 'tag=symbols-panel' in log
+ if left < -0.002 or left+width > 1.002 or (not scrolling_symbols and (top < -0.002 or top+height > 1.002)):
+ raise AssertionError(f'{name}: key outside window {tag}: {bounds}')
+ raw = out / (name + '-screen.png')
+ raw.write_bytes(adb('exec-out', 'screencap', '-p', binary=True))
+ Image.open(raw).crop((x, y, x+w, y+h)).save(out / (name + '.png'))
+ (out / (name + '-bounds.txt')).write_text(log)
+ records[:] = [record for record in records if record['case'] != name]
+ records.append({'case': name, 'window': [x,y,w,h], 'required': list(required), 'passed': True})
+ (out / 'results.json').write_text(json.dumps(records, ensure_ascii=False, indent=2))
+ print('PASS', name, flush=True)
+
+def panel(label, name, required=()):
+ command('mode:PINYIN_9')
+ tap('更多')
+ tap(label)
+ capture(name, required)
+
+def capture_voice_hold(name):
+ log = command('bounds')
+ x,y,w,h = map(int,re.search(r'window=(\d+),(\d+),(\d+),(\d+)',log).groups())
+ match = re.search(r'tag=key-space\|[^\n]*?\|([\d.eE,+-]+)',log)
+ left,top,width,height = map(float,match.group(1).split(','))
+ cx,cy = str(int(x+(left+width/2)*w)),str(int(y+(top+height/2)*h))
+ hold = subprocess.Popen(['adb','-s',serial,'shell','input','swipe',cx,cy,cx,cy,'3000'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
+ try:
+ time.sleep(.9)
+ capture(name, ('voice-inline-zone','voice-inline-status'))
+ finally:
+ hold.wait(timeout=15)
+ command('mode:PINYIN_26'); command('mode:PINYIN_9')
+
+def ui_tap(selector):
+ for attempt in range(5):
+ for node in ET.fromstring(tree()).iter('node'):
+ if selector in (node.get('resource-id'), node.get('text'), node.get('content-desc')):
+ x1,y1,x2,y2 = map(int, re.findall(r'\d+', node.get('bounds')))
+ if x2 > x1 and y2 > y1:
+ adb('shell','input','tap',str((x1+x2)//2),str((y1+y2)//2))
+ time.sleep(.7)
+ return
+ sizes = re.findall(r'(\d+)x(\d+)', adb('shell','wm','size'))
+ w,h = map(int,sizes[-1])
+ adb('shell','input','swipe',str(w//2),str(int(h*.8)),str(w//2),str(int(h*.3)),'250')
+ raise AssertionError('Missing visible UI target: ' + selector)
+
+def capture_app(name, required=()):
+ time.sleep(.5)
+ xml = tree()
+ for label in required:
+ if label not in xml: raise AssertionError(name + ': missing ' + label)
+ (out / (name + '.png')).write_bytes(adb('exec-out','screencap','-p',binary=True))
+ (out / (name + '-ui.xml')).write_text(xml)
+ records[:] = [record for record in records if record['case'] != name]
+ records.append({'case': name, 'required': list(required), 'passed': True})
+ (out / 'results.json').write_text(json.dumps(records,ensure_ascii=False,indent=2))
+ print('PASS', name, flush=True)
+
+def appearance(label):
+ command('mode:PINYIN_9')
+ tap('更多'); tap('设置'); tap(label)
+ command('mode:PINYIN_9')
+
+def write_pref(name, raw):
+ path = 'shared_prefs/' + name + '.xml'
+ if raw is None:
+ adb('shell','run-as',pkg,'rm','-f',path)
+ else:
+ subprocess.run(['adb','-s',serial,'shell',f"run-as {pkg} sh -c 'mkdir -p shared_prefs; cat > {path}'"],input=raw,check=True,capture_output=True)
+
+def pref_items(items):
+ root = ET.Element('map')
+ ET.SubElement(root,'string',{'name':'items'}).text = json.dumps(items,ensure_ascii=False)
+ return ET.tostring(root,encoding='utf-8',xml_declaration=True)
+
+pref_names = ['ime_settings','ime_quick_phrases','ime_custom_symbols','ime_clipboard_history']
+old_prefs = {}
+for name in pref_names:
+ result = subprocess.run(['adb','-s',serial,'shell','run-as',pkg,'cat','shared_prefs/'+name+'.xml'],capture_output=True)
+ old_prefs[name] = result.stdout if result.returncode == 0 else None
+
+old_size = adb('shell', 'wm', 'size')
+old_density = adb('shell', 'wm', 'density')
+old_font = adb('shell', 'settings', 'get', 'system', 'font_scale')
+old_locale = adb('shell', 'cmd', 'locale', 'get-app-locales', pkg)
+try:
+ adb('shell','am','force-stop',pkg)
+ settings = ET.fromstring(old_prefs['ime_settings'] or b'')
+ fixtures = {'skin_font': ('int','21'), 'skin_opacity': ('int','100'), 'skin_radius': ('int','8'), 'keyboard_height_percent': ('int','100'), 'floating_width_percent': ('int','100'), 'floating_opacity_percent': ('int','100'), 'sound': ('boolean','false'), 'haptic': ('boolean','true'), 'popup': ('boolean','true'), 'handedness': ('string','STANDARD'), 'skin_color': ('string','#1D9BF0')}
+ for name,(kind,value) in fixtures.items():
+ for node in list(settings):
+ if node.get('name') == name: settings.remove(node)
+ node = ET.SubElement(settings,kind,{'name':name})
+ if kind == 'string': node.text = value
+ else: node.set('value',value)
+ write_pref('ime_settings',ET.tostring(settings,encoding='utf-8',xml_declaration=True))
+ write_pref('ime_quick_phrases',pref_items([{'id':i+1,'category':'常用','text':text,'input_code':''} for i,text in enumerate(['会议室 B,下午三点','我到家了,不用担心','example@mail.com'])]))
+ write_pref('ime_custom_symbols',pref_items([{'id':i+1,'group':'常用箭头','symbol':text,'pinned':i==0} for i,text in enumerate(['→','★','¯\\_(ツ)_/¯'])]))
+ write_pref('ime_clipboard_history',pref_items([{'text':text,'timestamp':int(time.time()*1000),'pinned':i==0} for i,text in enumerate(['会议室 B,下午三点','example@mail.com','https://example.com/docs/getting-started'])]))
+ adb('shell', 'cmd', 'locale' , 'set-app-locales', pkg, '--locales', 'zh-CN')
+ adb('shell', 'ime', 'enable', pkg + '/.LocalVoiceImeService')
+ adb('shell', 'ime', 'set', pkg + '/.LocalVoiceImeService')
+ adb('shell', 'pm', 'grant', pkg, 'android.permission.RECORD_AUDIO')
+ launch()
+ for theme in ('浅色', '深色'):
+ appearance(theme)
+ stem = 'light' if theme == '浅色' else 'dark'
+ if panels_only:
+ command('clear-swipe'); command('mode:PINYIN_9')
+ capture(stem + '-nine-idle', ('key-9:9',))
+ command('mode:ENGLISH_26'); capture(stem + '-english26', ('key:q',))
+ for label,target,required in [('工具','tools',('tool:设置',)),('切换键盘','keyboard-select',('keyboard-choice-selected',)),('符号','symbols',('symbol-categories',)),('表情','emoji',('emoji-cell',)),('文本编辑','text-editor',('textedit-cross',)),('剪贴板','clipboard',('clip-card',)),('语音输入','voice',('voice-mic',)),('设置','preferences',('settings-slider:键盘高度',))]:
+ if label == '工具':
+ command('mode:PINYIN_9'); tap('更多'); capture(stem + '-' + target, required)
+ else: panel(label,stem + '-' + target,required)
+ continue
+ command('clear-swipe'); command('mode:PINYIN_9')
+ capture(stem + '-nine-idle', ('key-9:1', 'key-9:9', 'key-space'))
+ capture_voice_hold(stem + '-voice-hold')
+ command('nine-sequence:64426')
+ capture(stem + '-nine-composing', ('candidate-first', 'nine-pinyin-path-selected'))
+ tap('candidate-expand')
+ capture(stem + '-candidates-expanded', ('candidate-grid-first',))
+ tap('candidate-expand'); tap('key-space')
+ assert '你好' in editor(), 'Nine-key selection did not commit to the real editor'
+ command('clear-swipe'); command('mode:PINYIN_26')
+ for letter in 'nihao': tap('key:' + letter)
+ capture(stem + '-pinyin26', ('key:q', 'candidate-first'))
+ tap('key-space')
+ assert '你好' in editor(), '26-key selection did not commit to the real editor'
+ command('clear-swipe'); command('mode:ENGLISH_26')
+ capture(stem + '-english26', ('key:q', 'key:mode'))
+ before = editor()
+ tap('key:a'); tap('key:b'); tap('key:c')
+ assert editor() == before + 'abc', 'English input did not commit'
+ command('clear-swipe'); command('mode:DIGITS')
+ capture(stem + '-numeric', ('key:0', 'key:.', 'key:@'))
+ before = editor()
+ tap('key:1'); tap('key:0'); tap('key:.'); tap('key:5')
+ assert editor() == before + '10.5', 'Numeric layout commits wrong literals'
+ command('clear-swipe'); command('mode:PINYIN_9'); tap('更多')
+ capture(stem + '-tools', ('tool:语音输入', 'tool:设置', 'tool:文本编辑', 'tool:浮动键盘'))
+ for label, target, required in [
+ ('切换键盘','keyboard-select',('keyboard-choice-selected',)),
+ ('符号','symbols',('symbol-categories',)),
+ ('表情','emoji',('emoji-cell',)),
+ ('文本编辑','text-editor',('textedit-cross', 'textedit-action:paste')),
+ ('语音输入','voice',('voice-mic', 'segment-selected')),
+ ('剪贴板','clipboard',('segmented-track',)),
+ ('设置','preferences',('segmented-track-tall', 'settings-slider:键盘高度')),
+ ]:
+ panel(label, stem + '-' + target, required)
+ tap('强调色与按键皮肤')
+ capture(stem + '-skin', ('accent-swatch', 'accent-custom'))
+ tap('accent-custom')
+ capture_app(stem + '-accent-dialog', ('自定义强调色', '应用'))
+ adb('shell','input','text','5B6B7A')
+ capture_app(stem + '-accent-editor', ('5B6B7A',))
+ ui_tap('应用')
+ ui_tap('强调色蓝色,未选中')
+ launch()
+ command('mode:PINYIN_26'); tap('更多'); tap('浮动键盘')
+ capture(stem + '-floating', ('floating-drag-handle', 'key:q'))
+ tap('floating-drag-handle'); command('mode:PINYIN_9')
+ panel('设置', stem + '-preferences-return')
+ tap('模糊音与智能纠错')
+ capture(stem + '-fuzzy', ('fuzzy-rules',))
+ command('mode:PINYIN_9')
+ launch(); adb('shell','input','keyevent','4'); time.sleep(.5)
+ capture_app(stem + '-setup', ('open_app_settings',))
+ ui_tap(pkg + ':id/open_app_settings')
+ capture_app(stem + '-preferences-full', ('偏好设置', '强调色'))
+ ui_tap('关于与数据')
+ capture_app(stem + '-about', ('隐私','用户数据','导出','导入'))
+ launch()
+ panel('符号', stem + '-symbols-return')
+ tap('自定义'); tap('管理自定义符号')
+ capture_app(stem + '-symbol-manager', ('custom_symbol_text_editor', '完成'))
+ ui_tap('符号菜单')
+ capture_app(stem + '-symbol-menu', ('编辑','上移','下移','删除'))
+ adb('shell','input','keyevent','4'); ui_tap('完成'); launch()
+ panel('剪贴板', stem + '-clipboard-return')
+ tap('常用语')
+ capture(stem + '-quick-phrases', ('quick-phrase-add', 'phrase-card'))
+ tap('phrase-delete:2')
+ capture_app(stem + '-phrase-delete-dialog', ('删除常用语','删除'))
+ ui_tap('取消')
+ tap('quick-phrase-add')
+ command('mode:ENGLISH_26'); tap('key:a')
+ adb('shell','input','keyevent','4'); time.sleep(.5)
+ capture_app(stem + '-quick-phrase-editor', ('quick_phrase_text_editor','保存'))
+ ui_tap('取消')
+ capture_app(stem + '-discard-dialog', ('放弃未保存内容','继续编辑'))
+ ui_tap('放弃'); launch()
+ # Narrow phone, wide portrait, landscape, and large fonts use the same view.
+ for name, size, font in ([] if panels_only else [('narrow','840x1860','1.0'),('wide','1440x2560','1.0'),('landscape','2400x1080','1.0'),('large-font','1080x2400','1.3')]):
+ adb('shell', 'wm', 'size', size)
+ adb('shell', 'settings', 'put', 'system', 'font_scale', font)
+ time.sleep(0.7); launch(); command('clear-swipe'); appearance('浅色')
+ command('mode:PINYIN_9'); capture(name + '-nine', ('key-9:9',))
+ command('mode:PINYIN_26'); capture(name + '-pinyin26', ('key:q',))
+ print('All visual and real input scenarios passed.', flush=True)
+finally:
+ adb('shell','am','force-stop',pkg)
+ for name,raw in old_prefs.items(): write_pref(name,raw)
+ override = re.search(r'Override size: (\d+x\d+)', old_size)
+ adb('shell', 'wm', 'size', override.group(1) if override else 'reset')
+ override = re.search(r'Override density: (\d+)', old_density)
+ adb('shell', 'wm', 'density', override.group(1) if override else 'reset')
+ adb('shell', 'settings', 'put', 'system', 'font_scale', old_font if old_font != 'null' else '1.0')
+ match = re.search(r'\[(.*?)\]', old_locale)
+ adb('shell', 'cmd', 'locale', 'set-app-locales', pkg, '--locales', match.group(1) if match else '')
diff --git a/scripts/extract_reference_emoji.py b/scripts/extract_reference_emoji.py
new file mode 100644
index 00000000..7d2a59a4
--- /dev/null
+++ b/scripts/extract_reference_emoji.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+"""Extract the emoji artwork supplied on PDF page 28 at its original 3x scale.
+Usage: python3 scripts/extract_reference_emoji.py /absolute/path/to/design.pdf
+This reads the PDF image directly; no replacement artwork is generated.
+"""
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+from PIL import Image
+
+emojis = [
+ '😀','😃','😄','😁','😆','😅','😂','🤣',
+ '🥹','😊','😇','🙂','🙃','😉','😌','😍',
+ '🥰','😘','😗','😙','😚','☺️','😛','😝',
+ '😜','🤪','😳','🥺','🤓','😎','🥸','🤩',
+ '🥳','😏','😒','😞','😔','😟','😕','🙁',
+]
+output=Path(__file__).resolve().parents[1]/'app/src/main/assets/emoji/reference'
+output.mkdir(parents=True,exist_ok=True)
+with tempfile.TemporaryDirectory() as folder:
+ prefix=Path(folder)/'page'
+ subprocess.run(['pdfimages','-f','28','-l','28','-j',sys.argv[1],str(prefix)],check=True)
+ page=Image.open(sorted(Path(folder).glob('page-*.jpg'))[0]).convert('RGB')
+ if page.size!=(1170,906): raise ValueError('Expected the original 390x302 reference at 3x resolution')
+ background=(213,216,223)
+ for index,emoji in enumerate(emojis):
+ cx=(28+48*(index%8))*3; cy=(113+42*(index//8))*3
+ icon=page.crop((cx-42,cy-42,cx+42,cy+42)).convert('RGBA')
+ pixels=icon.load()
+ for y in range(icon.height):
+ for x in range(icon.width):
+ rgb=pixels[x,y][:3]
+ distance=max(abs(rgb[k]-background[k]) for k in range(3))
+ alpha=max(0.,min(1.,(distance-12)/48))
+ if alpha==0: pixels[x,y]=(0,0,0,0)
+ else:
+ foreground=tuple(max(0,min(255,round((rgb[k]-(1-alpha)*background[k])/alpha))) for k in range(3))
+ pixels[x,y]=(*foreground,round(alpha*255))
+ name='_'.join(format(ord(c),'x') for c in emoji)+'.png'
+ icon.save(output/name,optimize=True)
+print('Extracted',len(emojis),'reference emoji assets')
diff --git a/scripts/nine_key_regression.ps1 b/scripts/nine_key_regression.ps1
index 02ee9b52..bd1964a6 100644
--- a/scripts/nine_key_regression.ps1
+++ b/scripts/nine_key_regression.ps1
@@ -137,7 +137,7 @@ function AssertRapidSequence([int]$clickCount) {
if ($state -notmatch "compositionLength=$clickCount") {
throw "FAIL $clickCount rapid clicks lost/repeated input state=[$state]"
}
- Tap '确定' 300
+ Tap 'key-space' 300
AssertEqual "$clickCount rapid clicks commit" $committed (GetText ("p9-commit-$clickCount"))
if ((StateLog) -notmatch 'compositionLength=0') {
throw "FAIL $clickCount rapid clicks did not clear composition"
diff --git a/scripts/reference_phone_capture.py b/scripts/reference_phone_capture.py
new file mode 100644
index 00000000..77215c4d
--- /dev/null
+++ b/scripts/reference_phone_capture.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+"""Capture the real phone UI after installing the debug APK.
+Usage: python3 scripts/reference_phone_capture.py SERIAL OUTPUT [--apply-reference-skin]
+Only the explicit skin flag updates visual settings. It preserves other preferences
+and all dictionaries, clipboard entries, phrases and custom symbols.
+"""
+import json
+import re
+import subprocess
+import sys
+import time
+from pathlib import Path
+import xml.etree.ElementTree as ET
+from PIL import Image
+
+serial, output = sys.argv[1:3]
+out = Path(output).resolve(); out.mkdir(parents=True, exist_ok=True)
+pkg = 'llc.slacker.openime'
+def adb(*args, binary=False):
+ data = subprocess.run(['adb', '-s', serial, *args], check=True, capture_output=True).stdout
+ return data if binary else data.decode('utf-8', errors='replace')
+def cmd(value):
+ adb('logcat','-c')
+ adb('shell','am','broadcast','-n',pkg+'/.E2ETestReceiver','-a',pkg+'.TEST_COMMAND','--es','cmd',value)
+ time.sleep(.4)
+ log = adb('logcat','-d','-s','OpenImeE2E:I','OpenIme:I')
+ if 'ok=true' not in log: raise AssertionError(value + ': ' + log)
+ return log
+
+def tree():
+ adb('shell','uiautomator','dump','/sdcard/openime-reference.xml')
+ return adb('shell','cat','/sdcard/openime-reference.xml')
+
+def tap_ui(target):
+ for node in ET.fromstring(tree()).iter('node'):
+ if node.get('resource-id') == pkg+':id/'+target:
+ x1,y1,x2,y2 = map(int,re.findall(r'\d+',node.get('bounds')))
+ if y2>y1:
+ adb('shell','input','tap',str((x1+x2)//2),str((y1+y2)//2)); return
+ raise AssertionError('Missing visible ' + target)
+
+raw=adb('shell','run-as',pkg,'cat','shared_prefs/ime_settings.xml',binary=True)
+prefs=ET.fromstring(raw)
+old_mode=next((n.text for n in prefs if n.get('name')=='preferred_chinese_mode'),'PINYIN_26')
+if '--apply-reference-skin' in sys.argv:
+ for name,kind,value in [('skin_font','int','21'),('skin_opacity','int','100'),('skin_radius','int','8'),('skin_color','string','#1D9BF0'),('floating_width_percent','int','100')]:
+ for n in list(prefs):
+ if n.get('name')==name: prefs.remove(n)
+ n=ET.SubElement(prefs,kind,{'name':name})
+ if kind=='string': n.text=value
+ else: n.set('value',value)
+ adb('shell','am','force-stop',pkg)
+ subprocess.run(['adb','-s',serial,'shell',f"run-as {pkg} sh -c 'cat > shared_prefs/ime_settings.xml'"],input=ET.tostring(prefs,encoding='utf-8',xml_declaration=True),check=True,capture_output=True)
+
+old_ime = adb('shell','settings','get','secure','default_input_method').strip()
+adb('shell','am','force-stop',pkg)
+adb('shell','ime','set',pkg+'/.LocalVoiceImeService')
+time.sleep(.7)
+records=[]
+def capture(name, keyboard=True):
+ time.sleep(.6)
+ screen=Image.open(__import__('io').BytesIO(adb('exec-out','screencap','-p',binary=True)))
+ screen.save(out/(name+'-screen.png'))
+ if keyboard:
+ log=cmd('bounds'); box=re.search(r'window=(\d+),(\d+),(\d+),(\d+)',log)
+ if not box: raise AssertionError('Missing real IME window')
+ x,y,w,h=map(int,box.groups());screen.crop((x,y,x+w,y+h)).save(out/(name+'.png'))
+ (out/(name+'-bounds.txt')).write_text(log)
+ else:
+ screen.save(out/(name+'.png'));(out/(name+'-ui.xml')).write_text(tree())
+ records.append({'case':name,'captured':True})
+ print('CAPTURE',name,flush=True)
+try:
+ adb('shell','am','start','-n',pkg+'/.MainActivity');time.sleep(1)
+ adb('shell','input','keyevent','111');capture('phone-setup',False)
+ focused = False
+ for _ in range(5):
+ tap_ui('test_step');time.sleep(.7)
+ try:
+ if 'window=' in cmd('bounds'):
+ focused = True
+ break
+ except AssertionError:
+ time.sleep(.5)
+ if not focused: raise AssertionError('Cannot focus the real phone editor')
+ cmd('mode:PINYIN_9');capture('phone-nine-idle')
+ cmd('nine-sequence:64426');capture('phone-nine-composing')
+ cmd('tap:key-space')
+ # Verify a genuine commit into the Activity's actual EditText.
+ ui=tree()
+ if not any(n.get('resource-id')==pkg+':id/test_input' and '你好' in n.get('text','') for n in ET.fromstring(ui).iter('node')):
+ raise AssertionError('Chinese input did not commit to the real phone editor')
+ for label,name in [('工具','tools'),('符号','symbols'),('表情','emoji'),('语音输入','voice'),('设置','preferences')]:
+ cmd('mode:PINYIN_9');cmd('tap:更多')
+ if label!='工具': cmd('tap:'+label)
+ capture('phone-'+name)
+ records.append({'case':'phone-chinese-commit','passed':True})
+finally:
+ try: cmd('mode:'+old_mode)
+ except AssertionError: pass
+ if old_ime and old_ime != pkg+'/.LocalVoiceImeService': adb('shell','ime','set',old_ime)
+ (out/'results.json').write_text(json.dumps(records,ensure_ascii=False,indent=2))
diff --git a/scripts/reference_visual_review.py b/scripts/reference_visual_review.py
new file mode 100644
index 00000000..a866b402
--- /dev/null
+++ b/scripts/reference_visual_review.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+"""Generate review artifacts from the PDF and measured Android E2E screenshots.
+Usage: python3 scripts/reference_visual_review.py DESIGN.pdf OUTPUT_DIRECTORY
+Images retain their aspect ratio; system navigation is cropped from IME bounds.
+"""
+import html
+import json
+import re
+import subprocess
+import sys
+from pathlib import Path
+from PIL import Image, ImageDraw, ImageFont
+
+pdf, output = Path(sys.argv[1]), Path(sys.argv[2]).resolve()
+reference = output / 'reference'
+review = output / 'review'
+reference.mkdir(parents=True, exist_ok=True)
+review.mkdir(parents=True, exist_ok=True)
+subprocess.run(['pdfimages', '-j', str(pdf), str(reference / 'page')], check=True)
+font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 15)
+
+def resize(im, width):
+ return im.resize((width, round(im.height * width / im.width)), Image.Resampling.LANCZOS)
+
+def measured_image(path):
+ im = Image.open(path).convert('RGB')
+ bounds = path.with_name(path.stem + '-bounds.txt')
+ if bounds.exists():
+ match = re.search(r'tag=main-dock\|desc=[^|]*\|([\d.eE,+-]+)', bounds.read_text())
+ if match:
+ x, y, w, h = map(float, match.group(1).split(','))
+ im = im.crop((round(x * im.width), round(y * im.height),
+ round((x+w) * im.width), round((y+h) * im.height)))
+ return im
+
+pairs = [
+ ('phone/phone-nine-idle', 1, '实机 · 九键'),
+ ('phone/phone-nine-composing', 3, '实机 · 拼音候选'),
+ ('phone/phone-tools', 22, '实机 · 工具'),
+ ('phone/phone-symbols', 27, '实机 · 符号'),
+ ('phone/phone-emoji', 28, '实机 · 表情'),
+ ('phone/phone-voice', 29, '实机 · 语音'),
+ ('phone/phone-preferences', 30, '实机 · 偏好设置'),
+ ('light-pinyin26', 11, '拼音 26 键'),
+ ('light-english26', 12, '英文 26 键'),
+ ('light-numeric', 13, '数字键盘'),
+ ('light-keyboard-select', 23, '切换键盘'),
+ ('light-clipboard', 24, '剪贴板'),
+ ('light-quick-phrases', 25, '常用语'),
+ ('light-text-editor', 26, '文本编辑'),
+ ('light-skin', 31, '强调色与皮肤'),
+ ('light-fuzzy', 32, '模糊音'),
+ ('dark-nine-idle', 6, '深色九键'),
+ ('dark-tools', 34, '深色工具'),
+ ('dark-preferences', 33, '深色设置'),
+]
+cards = []
+phone_rows = []
+for stem, page, label in pairs:
+ source = output / (stem + '.png')
+ if not source.exists():
+ raise FileNotFoundError(source)
+ a = resize(Image.open(reference / f'page-{page-1:03d}.jpg').convert('RGB'), 390)
+ b = resize(measured_image(source), 390)
+ # Preserve both actual heights. Never stretch a capture to disguise mismatch.
+ canvas = Image.new('RGB', (800, max(a.height, b.height) + 32), '#f8fafc')
+ draw = ImageDraw.Draw(canvas)
+ draw.text((8, 8), f'PDF {page:02d}', fill='#475569', font=font)
+ draw.text((408, 8), 'Android / phone' if stem.startswith('phone/') else 'Android / emulator', fill='#475569', font=font)
+ canvas.paste(a, (0, 32)); canvas.paste(b, (410, 32))
+ name = stem.replace('/', '-') + '.png'
+ canvas.save(review / name)
+ cards.append(f'{html.escape(label)} · 设计稿第 {page} 页
')
+ if stem.startswith('phone/') and 'composing' not in stem:
+ phone_rows.append(canvas)
+
+overview = Image.new('RGB', (800, sum(im.height for im in phone_rows) + 12*(len(phone_rows)-1)), '#cbd5e1')
+y = 0
+for im in phone_rows:
+ overview.paste(im, (0, y)); y += im.height + 12
+overview.save(output / 'comparison.png')
+
+for name in ['narrow', 'wide', 'landscape', 'large-font']:
+ for keyboard in ['nine', 'pinyin26']:
+ path = output / f'{name}-{keyboard}.png'
+ im = measured_image(path)
+ im.save(review / path.name)
+ cards.append(f'{name} · {keyboard} · 实测 {im.width} × {im.height}
')
+
+(output / 'review.html').write_text('''
+openIME 设计稿对照
+
+openIME · 设计稿与真实 Android 对照
+每组左侧为 PDF 原图,右侧为实机或模拟器截图,统一到 390 宽,仅等比例缩放。输入内容、回车动作、权限与模型状态按实际运行显示。横屏按可用高度收敛键盘尺寸;设置和工具面板可滚动。
+''' + '\n'.join(cards) + '')
+records = json.loads((output / 'results.json').read_text())
+phone = json.loads((output / 'phone/results.json').read_text())
+(output / 'README.md').write_text(f'''# openIME 本轮验收
+
+设计来源:`{pdf}`。参考画布 390 单位宽,实际尺寸由设备可用宽度计算;横屏同时限制可用高度。文字、图标、键位、间距、圆角同步缩放。
+
+- 完整 Android 端到端场景:{len(records)} 项通过,覆盖浅色、深色、窄屏 840×1860、宽屏 1440×2560、横屏 2400×1080、系统字体 1.3 倍。
+- 核心输入回归:7 项通过,验证九键、26 键、英文、数字、删除和真实提交。
+- 最终图标与原稿表情更新:重新执行 20 项面板场景。
+- 手机:1200×2670,截图 {sum(bool(r.get('captured')) for r in phone)} 个页面,真实编辑器提交“你好”通过。
+- 最终 APK 已覆盖安装到连接的手机及模拟器。构建和 Android Lint 通过。
+- 比对保留真实页面状态;没有将模型状态、用户数据或回车动作伪装为设计示例。
+
+查看 `review.html` 可逐页并排检查;`comparison.png` 为实机六个页面的设计稿对照。原始截图、控件实测 bounds 和结果 JSON 均在本目录。
+
+## 可重复验证
+
+在项目根目录执行:
+
+```bash
+./gradlew --offline :app:assembleDebug :app:lintDebug
+adb -s emulator-5554 install -r app/build/outputs/apk/debug/app-debug.apk
+python3 scripts/design_reference_e2e.py emulator-5554 output/reference-final
+bash scripts/core_regression.sh emulator-5554 app/build/outputs/apk/debug/app-debug.apk
+python3 scripts/reference_phone_capture.py PHONE_SERIAL output/reference-final/phone
+python3 scripts/reference_visual_review.py '{pdf}' output/reference-final
+```
+
+多尺寸脚本仅允许明确指定模拟器,结束时恢复显示尺寸、字体、外观和测试数据;手机脚本恢复原默认输入法。
+''')
+print('Saved comparison.png, review.html and README.md')
diff --git a/scripts/release_build.sh b/scripts/release_build.sh
new file mode 100755
index 00000000..60959a87
--- /dev/null
+++ b/scripts/release_build.sh
@@ -0,0 +1,130 @@
+#!/usr/bin/env bash
+# Build and verify the signed arm64 release APK. The release workflow and a
+# maintainer's machine run exactly this, so the pipeline can be rehearsed locally.
+#
+# Required environment (see docs/RELEASE.md):
+# OPENIME_KEYSTORE_PATH OPENIME_KEYSTORE_PASSWORD OPENIME_KEY_ALIAS OPENIME_KEY_PASSWORD
+# Optional:
+# OPENIME_RELEASE_TAG tag being released; must equal v
+# OPENIME_REHEARSAL=1 the keystore is a throwaway: skip the certificate continuity check
+# OPENIME_SKIP_TESTS=1 skip :app:testDebugUnitTest (quick local runs only)
+# OPENIME_OUT_DIR output directory (default: build/release-files)
+# OPENIME_GRADLE_ARGS extra Gradle arguments, e.g. --offline
+# ANDROID_HOME SDK containing build-tools/35.0.0 (apksigner, aapt2)
+#
+# Writes to OPENIME_OUT_DIR: openIME-v-arm64-release.apk, SHA256SUMS.txt,
+# THIRD_PARTY_NOTICES.release.md, RELEASE_NOTES.md, apksigner.txt.
+# Also appends apk=, version= and cert_sha256= to $GITHUB_OUTPUT when it is set.
+set -euo pipefail
+export PYTHONUTF8=1
+
+cd "$(dirname "$0")/.."
+
+die() { echo "release_build: $*" >&2; exit 1; }
+
+# mapfile and empty-array expansion under `set -u` need bash 4.4+ (macOS ships 3.2).
+if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4) )); then
+ die "bash 4.4 or newer is required (found $BASH_VERSION)"
+fi
+
+for name in OPENIME_KEYSTORE_PATH OPENIME_KEYSTORE_PASSWORD OPENIME_KEY_ALIAS OPENIME_KEY_PASSWORD; do
+ [[ -n "${!name:-}" ]] || die "missing $name"
+done
+[[ -f "$OPENIME_KEYSTORE_PATH" ]] || die "keystore not found: $OPENIME_KEYSTORE_PATH"
+
+SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
+BUILD_TOOLS="$SDK/build-tools/${OPENIME_BUILD_TOOLS:-35.0.0}"
+APKSIGNER="$BUILD_TOOLS/apksigner"
+AAPT2="$BUILD_TOOLS/aapt2"
+[[ -x "$APKSIGNER" && -x "$AAPT2" ]] || die "apksigner/aapt2 not found in $BUILD_TOOLS"
+
+OUT="${OPENIME_OUT_DIR:-build/release-files}"
+rm -rf "$OUT"
+mkdir -p "$OUT"
+
+# 1. Version, changelog and (when releasing) tag agree.
+check_args=(check)
+[[ -n "${OPENIME_RELEASE_TAG:-}" ]] && check_args+=(--tag "$OPENIME_RELEASE_TAG")
+python3 scripts/release_check.py "${check_args[@]}"
+VERSION="$(python3 scripts/release_check.py version | cut -d' ' -f1)"
+
+# 2. Build. lintRelease runs here because pull-request CI only lints the debug variant.
+tasks=(:app:lintRelease :app:assembleRelease)
+[[ "${OPENIME_SKIP_TESTS:-0}" == "1" ]] || tasks=(:app:testDebugUnitTest "${tasks[@]}")
+read -r -a extra_gradle_args <<< "${OPENIME_GRADLE_ARGS:-}"
+./gradlew "${tasks[@]}" --no-daemon --console=plain "${extra_gradle_args[@]}"
+
+APK="$(find app/build/outputs/apk/release -maxdepth 1 -type f -name 'app-release*.apk' | head -n 1)"
+[[ -n "$APK" && -f "$APK" ]] || die "release APK not found (was signing configured?)"
+
+# 3. The APK is signed with the release key and nothing else.
+"$APKSIGNER" verify --verbose --print-certs "$APK" | tee "$OUT/apksigner.txt"
+grep -Eq 'Verified using v[23][^:]*: true' "$OUT/apksigner.txt" || die "no v2/v3 signature"
+grep -q 'CN=Android Debug' "$OUT/apksigner.txt" && die "APK is signed with the Android debug certificate"
+CERT_SHA256="$(sed -n 's/^Signer #1 certificate SHA-256 digest: //p' "$OUT/apksigner.txt" | head -n 1)"
+[[ -n "$CERT_SHA256" ]] || die "could not read the signing certificate digest"
+
+# 4. arm64-v8a only.
+mapfile -t ABIS < <(unzip -Z1 "$APK" | awk -F/ '/^lib\/[^/]+\/[^/]+$/ {print $2}' | sort -u)
+if [[ "${#ABIS[@]}" -ne 1 || "${ABIS[0]}" != "arm64-v8a" ]]; then
+ die "release APK must contain only arm64-v8a, found: ${ABIS[*]:-none}"
+fi
+
+# 5. The identity baked into the APK is the one in VERSION.
+python3 scripts/release_check.py apk "$APK" --aapt2 "$AAPT2"
+
+# 6. Every release must be signed by the same key, or nobody can update in place.
+# docs/release-cert.sha256 holds the digest; it is recorded after the first release.
+if [[ "${OPENIME_REHEARSAL:-0}" != "1" ]]; then
+ EXPECTED=""
+ if [[ -f docs/release-cert.sha256 ]]; then
+ EXPECTED="$(tr -d '[:space:]:' < docs/release-cert.sha256 | tr 'A-F' 'a-f')"
+ fi
+ ACTUAL="$(printf '%s' "$CERT_SHA256" | tr -d ':' | tr 'A-F' 'a-f')"
+ if [[ -z "$EXPECTED" || "$EXPECTED" == "unset" ]]; then
+ echo "::notice title=First release::Record the signing certificate in docs/release-cert.sha256: $ACTUAL"
+ elif [[ "$EXPECTED" != "$ACTUAL" ]]; then
+ die "signing certificate $ACTUAL does not match docs/release-cert.sha256 ($EXPECTED); a different key would break in-place updates"
+ fi
+fi
+
+# 7. Release files.
+NAME="openIME-v${VERSION}-arm64-release.apk"
+cp "$APK" "$OUT/$NAME"
+cp THIRD_PARTY_NOTICES.md "$OUT/THIRD_PARTY_NOTICES.release.md"
+(cd "$OUT" && sha256sum "$NAME" > SHA256SUMS.txt)
+
+{
+ python3 scripts/release_check.py notes
+ cat < "$OUT/RELEASE_NOTES.md"
+
+echo "release files in $OUT:"
+ls -l "$OUT"
+
+if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
+ {
+ echo "apk=$NAME"
+ echo "version=$VERSION"
+ echo "cert_sha256=$CERT_SHA256"
+ echo "out_dir=$OUT"
+ } >> "$GITHUB_OUTPUT"
+fi
diff --git a/scripts/release_check.py b/scripts/release_check.py
new file mode 100755
index 00000000..b1b56811
--- /dev/null
+++ b/scripts/release_check.py
@@ -0,0 +1,256 @@
+#!/usr/bin/env python3
+"""Release metadata checks shared by CI, the release workflow and maintainers.
+
+The root VERSION file is the single source of truth. app/build.gradle.kts reads
+it and derives versionCode with the same formula used here, so this script can
+verify both the repository and a built APK.
+
+ release_check.py version print VERSION and the derived versionCode
+ release_check.py check [--tag vX.Y.Z] VERSION is valid, CHANGELOG.md has the dated
+ section for it right below [Unreleased], and
+ (with --tag) the tag is exactly v
+ release_check.py notes [--version X.Y.Z] print that CHANGELOG section (release notes body)
+ release_check.py apk PATH [--aapt2 PATH] versionName / versionCode / package inside the APK
+ match VERSION
+
+Exit status is non-zero on any problem. Messages are GitHub Actions annotations
+when run there.
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime
+import glob
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+from typing import NamedTuple, Optional
+
+ROOT = Path(__file__).resolve().parent.parent
+APPLICATION_ID = "llc.slacker.openime"
+
+SEMVER = re.compile(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)")
+UNRELEASED_HEADING = re.compile(r"##\s+\[Unreleased\]\s*")
+RELEASE_HEADING = re.compile(
+ r"##\s+\[(?P[^\]]+)\]\s+-\s+(?P\d{4}-\d{2}-\d{2})(?P\s+\[YANKED\])?\s*"
+)
+
+
+class ReleaseCheckError(Exception):
+ """A problem a maintainer has to fix before releasing."""
+
+
+class Section(NamedTuple):
+ version: str
+ date: str
+ yanked: bool
+ body: str
+
+
+def parse_version(text: str) -> tuple[int, int, int]:
+ match = SEMVER.fullmatch(text.strip())
+ if not match:
+ raise ReleaseCheckError(
+ f"version '{text.strip()}' must be MAJOR.MINOR.PATCH without a prefix or suffix"
+ )
+ major, minor, patch = (int(part) for part in match.groups())
+ if major < 1 or minor > 99 or patch > 99:
+ raise ReleaseCheckError(
+ f"version {text.strip()} is out of range (major >= 1, minor and patch <= 99)"
+ )
+ return major, minor, patch
+
+
+def version_code(version: str) -> int:
+ """Same formula as app/build.gradle.kts: major * 10000 + minor * 100 + patch."""
+ major, minor, patch = parse_version(version)
+ return major * 10_000 + minor * 100 + patch
+
+
+def read_version(root: Path = ROOT) -> str:
+ version = (root / "VERSION").read_text(encoding="utf-8").strip()
+ parse_version(version)
+ return version
+
+
+def parse_changelog(text: str) -> tuple[bool, list[Section]]:
+ """Return (has_unreleased_heading_first, released sections in file order)."""
+ headings: list[tuple[int, str]] = []
+ lines = text.splitlines()
+ for index, line in enumerate(lines):
+ if line.startswith("## "):
+ headings.append((index, line))
+ if not headings:
+ return False, []
+
+ unreleased_first = bool(UNRELEASED_HEADING.fullmatch(headings[0][1]))
+ sections: list[Section] = []
+ for position, (index, heading) in enumerate(headings):
+ match = RELEASE_HEADING.fullmatch(heading)
+ if not match:
+ if position == 0 and unreleased_first:
+ continue
+ raise ReleaseCheckError(
+ f"CHANGELOG.md heading '{heading}' must be '## [Unreleased]' or "
+ "'## [X.Y.Z] - YYYY-MM-DD'"
+ )
+ end = headings[position + 1][0] if position + 1 < len(headings) else len(lines)
+ body_lines = lines[index + 1:end]
+ # Link reference definitions at the very bottom belong to no section.
+ while body_lines and (
+ not body_lines[-1].strip() or re.fullmatch(r"\[[^\]]+\]:\s+\S+", body_lines[-1].strip())
+ ):
+ body_lines.pop()
+ sections.append(
+ Section(
+ version=match.group("version"),
+ date=match.group("date"),
+ yanked=bool(match.group("yanked")),
+ body="\n".join(body_lines).strip(),
+ )
+ )
+ return unreleased_first, sections
+
+
+def check_changelog(text: str, version: str) -> None:
+ unreleased_first, sections = parse_changelog(text)
+ if not unreleased_first:
+ raise ReleaseCheckError("CHANGELOG.md must start with '## [Unreleased]'")
+ if not sections:
+ raise ReleaseCheckError(f"CHANGELOG.md has no section for {version}")
+
+ previous: Optional[tuple[int, int, int]] = None
+ previous_date: Optional[datetime.date] = None
+ for section in sections:
+ numbers = parse_version(section.version)
+ try:
+ when = datetime.date.fromisoformat(section.date)
+ except ValueError as error:
+ raise ReleaseCheckError(
+ f"CHANGELOG.md [{section.version}] has an invalid date '{section.date}'"
+ ) from error
+ if not section.body:
+ raise ReleaseCheckError(f"CHANGELOG.md [{section.version}] is empty")
+ if previous is not None and numbers >= previous:
+ raise ReleaseCheckError(
+ f"CHANGELOG.md versions must be strictly descending; [{section.version}] "
+ "is not below the section above it"
+ )
+ if previous_date is not None and when > previous_date:
+ raise ReleaseCheckError(
+ f"CHANGELOG.md [{section.version}] is dated after the newer release above it"
+ )
+ previous, previous_date = numbers, when
+
+ top = sections[0]
+ if top.version != version:
+ raise ReleaseCheckError(
+ f"VERSION is {version} but the newest CHANGELOG.md section is [{top.version}]. "
+ "A version bump and its changelog section land together (see docs/RELEASE.md)."
+ )
+ if top.yanked:
+ raise ReleaseCheckError(f"CHANGELOG.md marks [{version}] as [YANKED]; bump the version")
+
+
+def changelog_notes(text: str, version: str) -> str:
+ _, sections = parse_changelog(text)
+ for section in sections:
+ if section.version == version:
+ if not section.body:
+ raise ReleaseCheckError(f"CHANGELOG.md [{version}] is empty")
+ return section.body
+ raise ReleaseCheckError(f"CHANGELOG.md has no section for {version}")
+
+
+BADGING = re.compile(
+ r"package: name='(?P[^']+)' versionCode='(?P\d+)' versionName='(?P[^']*)'"
+)
+
+
+def parse_badging(text: str) -> tuple[str, int, str]:
+ match = BADGING.search(text)
+ if not match:
+ raise ReleaseCheckError("could not read the package line from 'aapt2 dump badging'")
+ return match.group("name"), int(match.group("code")), match.group("version")
+
+
+def find_aapt2(explicit: Optional[str]) -> str:
+ if explicit:
+ return explicit
+ sdk = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")
+ if sdk:
+ candidates = sorted(glob.glob(os.path.join(sdk, "build-tools", "*", "aapt2")))
+ if candidates:
+ return candidates[-1]
+ return "aapt2"
+
+
+def check_apk(apk: Path, version: str, aapt2: Optional[str]) -> None:
+ if not apk.is_file():
+ raise ReleaseCheckError(f"APK not found: {apk}")
+ tool = find_aapt2(aapt2)
+ try:
+ result = subprocess.run(
+ [tool, "dump", "badging", str(apk)], capture_output=True, text=True, check=True
+ )
+ except (OSError, subprocess.CalledProcessError) as error:
+ raise ReleaseCheckError(f"'{tool} dump badging' failed: {error}") from error
+ name, code, shown = parse_badging(result.stdout)
+ problems = []
+ if name != APPLICATION_ID:
+ problems.append(f"package is {name}, expected {APPLICATION_ID}")
+ if shown != version:
+ problems.append(f"versionName is {shown}, VERSION says {version}")
+ if code != version_code(version):
+ problems.append(f"versionCode is {code}, expected {version_code(version)}")
+ if problems:
+ raise ReleaseCheckError(f"{apk.name}: " + "; ".join(problems))
+
+
+def fail(message: str) -> int:
+ if os.environ.get("GITHUB_ACTIONS") == "true":
+ print(f"::error title=Release check::{message}")
+ else:
+ print(f"error: {message}", file=sys.stderr)
+ return 1
+
+
+def main(argv: Optional[list[str]] = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawTextHelpFormatter)
+ parser.add_argument("--root", type=Path, default=ROOT, help="repository root (tests only)")
+ sub = parser.add_subparsers(dest="command", required=True)
+ sub.add_parser("version")
+ check = sub.add_parser("check")
+ check.add_argument("--tag", help="release tag that must equal v")
+ notes = sub.add_parser("notes")
+ notes.add_argument("--version")
+ apk = sub.add_parser("apk")
+ apk.add_argument("path", type=Path)
+ apk.add_argument("--aapt2")
+ args = parser.parse_args(argv)
+
+ try:
+ version = read_version(args.root)
+ changelog = (args.root / "CHANGELOG.md").read_text(encoding="utf-8")
+ if args.command == "version":
+ print(f"{version} {version_code(version)}")
+ elif args.command == "check":
+ if args.tag is not None and args.tag != f"v{version}":
+ raise ReleaseCheckError(f"tag {args.tag} does not match VERSION {version} (expected v{version})")
+ check_changelog(changelog, version)
+ print(f"OK: {version} (versionCode {version_code(version)})")
+ elif args.command == "notes":
+ print(changelog_notes(changelog, args.version or version))
+ elif args.command == "apk":
+ check_apk(args.path, version, args.aapt2)
+ print(f"OK: {args.path.name} is {APPLICATION_ID} {version} ({version_code(version)})")
+ except (ReleaseCheckError, OSError) as error:
+ return fail(str(error))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/setup_release_signing.sh b/scripts/setup_release_signing.sh
new file mode 100755
index 00000000..b93bb442
--- /dev/null
+++ b/scripts/setup_release_signing.sh
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+# One-time setup of the release signing key. Run it yourself, on a machine you
+# trust: the passwords are generated here, written only to the key directory
+# and to GitHub Actions secrets, and never printed.
+#
+# bash scripts/setup_release_signing.sh [--repo OWNER/NAME] [--dir DIR] [--no-upload]
+#
+# It creates DIR/openime-release.jks (default ~/.openime-release) and uploads the four
+# secrets that .github/workflows/release.yml reads:
+# OPENIME_KEYSTORE_B64 OPENIME_KEYSTORE_PASSWORD OPENIME_KEY_ALIAS OPENIME_KEY_PASSWORD
+#
+# THE KEY IS THE APP'S PERMANENT IDENTITY. Android only updates an app in place
+# when the new APK is signed by the same key, so a lost key means every user has
+# to uninstall. Back DIR up (offline copy plus a password manager) before the
+# first release. Nothing in this repository can recover it.
+set -euo pipefail
+umask 077
+
+REPO=""
+DIR="${OPENIME_KEY_DIR:-$HOME/.openime-release}"
+UPLOAD=1
+FORCE=0
+ALIAS="openime"
+DNAME="${OPENIME_DNAME:-CN=openIME, O=Slacker LLC}"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --repo) REPO="$2"; shift 2 ;;
+ --dir) DIR="$2"; shift 2 ;;
+ --no-upload) UPLOAD=0; shift ;;
+ --force) FORCE=1; shift ;;
+ -h|--help) sed -n '2,17p' "$0"; exit 0 ;;
+ *) echo "unknown argument: $1" >&2; exit 2 ;;
+ esac
+done
+
+die() { echo "setup_release_signing: $*" >&2; exit 1; }
+
+command -v keytool >/dev/null || die "keytool (JDK 17) not found"
+if [[ "$UPLOAD" == "1" ]]; then
+ command -v gh >/dev/null || die "gh (GitHub CLI) not found; install it or use --no-upload"
+ gh auth status >/dev/null 2>&1 || die "run 'gh auth login' first"
+ [[ -n "$REPO" ]] || REPO="$(gh repo view --json nameWithOwner --jq .nameWithOwner)"
+fi
+
+KEYSTORE="$DIR/openime-release.jks"
+[[ ! -e "$KEYSTORE" ]] || die "$KEYSTORE already exists; refusing to overwrite a signing key"
+
+if [[ "$UPLOAD" == "1" ]]; then
+ existing="$(gh secret list --repo "$REPO" --json name --jq '.[].name' | grep -c '^OPENIME_KEY' || true)"
+ if [[ "$existing" -gt 0 && "$FORCE" != "1" ]]; then
+ die "$REPO already has OPENIME_* secrets. Replacing the key after a release breaks in-place updates; pass --force only if no release has shipped."
+ fi
+fi
+
+mkdir -p "$DIR"
+chmod 700 "$DIR"
+# `|| true`: head closes the pipe early and pipefail would otherwise abort the script.
+PASSWORD="$(head -c 96 /dev/urandom | base64 -w0 | tr -dc 'A-Za-z0-9' | head -c 40 || true)"
+[[ "${#PASSWORD}" -eq 40 ]] || die "could not generate a password"
+
+# PKCS12 uses one password for the store and the key.
+if ! output="$(keytool -genkeypair -keystore "$KEYSTORE" -storetype PKCS12 \
+ -alias "$ALIAS" -keyalg RSA -keysize 4096 -validity 10000 \
+ -storepass "$PASSWORD" -keypass "$PASSWORD" -dname "$DNAME" 2>&1)"; then
+ rm -f "$KEYSTORE"
+ die "keytool failed: ${output//$PASSWORD/***}"
+fi
+chmod 600 "$KEYSTORE"
+
+cat > "$DIR/credentials.txt" </dev/null \
+ | sed -n 's/^[[:space:]]*SHA256: //p' | head -n 1 | tr -d ':' | tr 'A-F' 'a-f')"
+
+if [[ "$UPLOAD" == "1" ]]; then
+ base64 -w0 "$KEYSTORE" | gh secret set OPENIME_KEYSTORE_B64 --repo "$REPO"
+ printf '%s' "$PASSWORD" | gh secret set OPENIME_KEYSTORE_PASSWORD --repo "$REPO"
+ printf '%s' "$ALIAS" | gh secret set OPENIME_KEY_ALIAS --repo "$REPO"
+ printf '%s' "$PASSWORD" | gh secret set OPENIME_KEY_PASSWORD --repo "$REPO"
+ echo "Uploaded 4 secrets to $REPO."
+else
+ echo "Skipped upload (--no-upload). Set the four secrets yourself; see docs/RELEASE.md."
+fi
+
+cat < None:
+ self.assertEqual(rc.version_code("1.0.0"), 10000)
+ self.assertEqual(rc.version_code("1.2.3"), 10203)
+ self.assertEqual(rc.version_code("2.10.99"), 21099)
+
+ def test_every_release_is_greater_than_the_one_before(self) -> None:
+ order = ["1.0.0", "1.0.1", "1.0.99", "1.1.0", "1.99.99", "2.0.0"]
+ codes = [rc.version_code(version) for version in order]
+ self.assertEqual(codes, sorted(set(codes)))
+
+ def test_rejects_anything_but_plain_semver(self) -> None:
+ for bad in ["1.0", "v1.0.0", "1.0.0-rc1", "1.0.0+build", "01.0.0", "0.9.0", "1.100.0", "1.0.100", ""]:
+ with self.subTest(bad=bad), self.assertRaises(rc.ReleaseCheckError):
+ rc.parse_version(bad)
+
+
+class ChangelogTests(unittest.TestCase):
+ def test_accepts_a_consistent_changelog(self) -> None:
+ rc.check_changelog(CHANGELOG, "1.1.0")
+
+ def test_version_must_match_the_newest_section(self) -> None:
+ with self.assertRaisesRegex(rc.ReleaseCheckError, "newest CHANGELOG"):
+ rc.check_changelog(CHANGELOG, "1.0.0")
+ with self.assertRaisesRegex(rc.ReleaseCheckError, "newest CHANGELOG"):
+ rc.check_changelog(CHANGELOG, "1.2.0")
+
+ def test_requires_unreleased_first(self) -> None:
+ text = CHANGELOG.replace("## [Unreleased]", "## [Next]")
+ with self.assertRaises(rc.ReleaseCheckError):
+ rc.check_changelog(text, "1.1.0")
+
+ def test_rejects_empty_sections(self) -> None:
+ text = "## [Unreleased]\n\n## [1.0.0] - 2026-10-02\n\n"
+ with self.assertRaisesRegex(rc.ReleaseCheckError, "empty"):
+ rc.check_changelog(text, "1.0.0")
+
+ def test_rejects_ascending_versions_and_dates(self) -> None:
+ ascending = "## [Unreleased]\n\n## [1.0.0] - 2026-10-02\n- a\n\n## [1.1.0] - 2026-09-01\n- b\n"
+ with self.assertRaisesRegex(rc.ReleaseCheckError, "descending"):
+ rc.check_changelog(ascending, "1.0.0")
+ later = "## [Unreleased]\n\n## [1.1.0] - 2026-10-01\n- a\n\n## [1.0.0] - 2026-10-02\n- b\n"
+ with self.assertRaisesRegex(rc.ReleaseCheckError, "dated after"):
+ rc.check_changelog(later, "1.1.0")
+
+ def test_rejects_bad_dates_and_headings(self) -> None:
+ with self.assertRaisesRegex(rc.ReleaseCheckError, "invalid date"):
+ rc.check_changelog("## [Unreleased]\n\n## [1.0.0] - 2026-13-45\n- a\n", "1.0.0")
+ with self.assertRaisesRegex(rc.ReleaseCheckError, "must be"):
+ rc.check_changelog("## [Unreleased]\n\n## 1.0.0\n- a\n", "1.0.0")
+
+ def test_a_yanked_newest_release_is_not_a_valid_target(self) -> None:
+ text = "## [Unreleased]\n\n## [1.0.0] - 2026-10-02 [YANKED]\n- a\n"
+ with self.assertRaisesRegex(rc.ReleaseCheckError, "YANKED"):
+ rc.check_changelog(text, "1.0.0")
+
+ def test_notes_are_exactly_the_section_body(self) -> None:
+ self.assertEqual(rc.changelog_notes(CHANGELOG, "1.1.0"), "### 新增\n- 新功能。")
+ # The last section stops before the link reference definitions.
+ self.assertEqual(rc.changelog_notes(CHANGELOG, "1.0.0"), "首个正式版。")
+ with self.assertRaises(rc.ReleaseCheckError):
+ rc.changelog_notes(CHANGELOG, "9.9.9")
+
+
+class BadgingTests(unittest.TestCase):
+ SAMPLE = (
+ "package: name='llc.slacker.openime' versionCode='10000' versionName='1.0.0' "
+ "platformBuildVersionName='16' compileSdkVersion='36'\nsdkVersion:'26'\n"
+ )
+
+ def test_parses_the_package_line(self) -> None:
+ self.assertEqual(rc.parse_badging(self.SAMPLE), ("llc.slacker.openime", 10000, "1.0.0"))
+
+ def test_rejects_output_without_a_package_line(self) -> None:
+ with self.assertRaises(rc.ReleaseCheckError):
+ rc.parse_badging("ERROR: not an apk")
+
+
+class CommandTests(unittest.TestCase):
+ def run_main(self, root: Path, *argv: str) -> tuple[int, str]:
+ out, err = io.StringIO(), io.StringIO()
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ status = rc.main(["--root", str(root), *argv])
+ return status, out.getvalue() + err.getvalue()
+
+ def fixture(self, version: str) -> Path:
+ directory = Path(tempfile.mkdtemp())
+ self.addCleanup(lambda: [path.unlink() for path in directory.iterdir()] and directory.rmdir())
+ (directory / "VERSION").write_text(version + "\n", encoding="utf-8")
+ (directory / "CHANGELOG.md").write_text(CHANGELOG, encoding="utf-8")
+ return directory
+
+ def test_check_passes_and_prints_the_version_code(self) -> None:
+ status, output = self.run_main(self.fixture("1.1.0"), "check")
+ self.assertEqual(status, 0, output)
+ self.assertIn("10100", output)
+
+ def test_check_enforces_the_tag(self) -> None:
+ root = self.fixture("1.1.0")
+ self.assertEqual(self.run_main(root, "check", "--tag", "v1.1.0")[0], 0)
+ status, output = self.run_main(root, "check", "--tag", "v1.0.0")
+ self.assertEqual(status, 1)
+ self.assertIn("does not match", output)
+ self.assertEqual(self.run_main(root, "check", "--tag", "1.1.0")[0], 1)
+
+ def test_a_version_bump_without_a_changelog_section_fails(self) -> None:
+ status, output = self.run_main(self.fixture("1.2.0"), "check")
+ self.assertEqual(status, 1)
+ self.assertIn("newest CHANGELOG", output)
+
+ def test_notes_default_to_the_current_version(self) -> None:
+ status, output = self.run_main(self.fixture("1.1.0"), "notes")
+ self.assertEqual(status, 0)
+ self.assertIn("新功能", output)
+
+
+class RepositoryTests(unittest.TestCase):
+ def test_this_repository_is_consistent(self) -> None:
+ """The checked-in VERSION and CHANGELOG.md must satisfy the release rules."""
+ version = rc.read_version()
+ rc.check_changelog((rc.ROOT / "CHANGELOG.md").read_text(encoding="utf-8"), version)
+
+
+if __name__ == "__main__":
+ unittest.main()