diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac455a21..369ce26d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,18 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs
## [Unreleased]
+## [0.0.5-beta.1] - 2026-10-05
+
+第五个测试版:按键震动更响更清脆并可调强度,按键音效恢复,“中/英”键移到空格右边,整理“关于与数据”和工具面板。从 0.0.4-beta.1 覆盖安装即可,用户数据保留。
+
+- 按键震动更响更清脆:不再走系统给键盘的固定低振幅,直接驱动振动马达播放满强度的短促点击(Android 11 起用 CLICK 原语,Android 10 用系统预设点击,更早版本用 20ms 满幅脉冲),仍跟随系统“触摸反馈”强度;删除键手势到位也是同一下点击。
+- 新增“震动强度”滑块(10%–100%,默认 100%),在偏好设置“触感震动”下方和键盘内设置首屏;拖动时实时试震,随用户数据导出、导入。
+- 修复“按键音效”打开后没有声音:系统“触摸提示音”关闭时按键音也被一起静音,现在只看 App 内的开关。
+- 26 键的“中/英”键移到空格右边,与“,。”键对调,中英文键盘一致。
+- 删除键上滑清空后不再出现“5 秒内下滑撤回”和顶栏的“已清空 · 撤销”;工具栏的普通撤销按钮保留。
+- “关于与数据”拆成两页:“关于”(版本、隐私、诊断)和“数据管理”(导出与导入用户数据、语音词表、卸载前提示)。偏好设置和键盘内设置的最后一组改名为“关于与数据”,下面是这两项。
+- 键盘工具面板去掉“语音输入”(按住空格即可)和“切换键盘”(工具栏已有),新增“数据管理”。
+
## [0.0.4-beta.1] - 2026-10-04
第四个测试版:松开空格立即结束录音,已录音频继续完整转写;加入离线中英标点恢复,重新整理 App 首页与设置页。
diff --git a/README.md b/README.md
index 06233c5f..8954cf2c 100644
--- a/README.md
+++ b/README.md
@@ -26,14 +26,14 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库
- **九键**:输入时左栏列出下一个字的拼音,一个字选一个音节,选定后自动移到下一个字。
- **语音输入**:长按空格说话,松手后识别并上屏;使用内置的中英双语模型,不联网;可去掉“嗯”“呃”等语气词,也可把标点写成空格。
- **语音词表**:内置科技、应用、游戏词表,可导入自己的文本词表;识别后把同音词改成词表写法,不联网。
-- **手势**:删除键上滑清空,5 秒内下滑可撤回;空格左右滑动移动光标,滑动时底行其他按键锁定。
+- **手势**:删除键上滑清空;空格左右滑动移动光标,滑动时底行其他按键锁定。
- **表情联想**:选词后联想栏先给出相关表情(开心 → 😊),词表内置,不联网。
- **自动填充**:Android 11+ 上,密码管理器的账号、验证码直接显示在键盘工具栏位置。
- **数字和符号**:26 键字母键右上角印着数字和符号,上滑或长按直接输入。
- **适配**:横竖屏、平板与折叠屏、深色模式、大字号;终端、远程桌面、游戏等原始按键输入框;
外接键盘可直接打拼音。详见 [兼容性说明](docs/COMPATIBILITY.md)。
- **自我保护**:连续崩溃或卡死后自动进入安全模式,保证仍然可以打字;
- “设置 → 关于与数据”可复制诊断信息并导出、导入用户数据。
+ “设置 → 关于与数据”下,“关于”可复制诊断信息,“数据管理”可导出、导入用户数据。
## 下载与安装
diff --git a/VERSION b/VERSION
index 47875e82..b16e4f99 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.0.4-beta.1
+0.0.5-beta.1
diff --git a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt
index 1a3031ba..ea3f221a 100644
--- a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt
+++ b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt
@@ -87,7 +87,7 @@ class AuditInteractionInstrumentedTest {
}
"onFuzzyChanged" -> { fuzzyChanges.add(args!![0] as Boolean); null }
// A proxy answers null for everything else; a primitive boolean
- // query (hasClearUndo, onUndoClear) must answer false instead.
+ // query must answer false instead.
else -> if (method.returnType == java.lang.Boolean.TYPE) false else null
}
} as ImeKeyboardView.Listener
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 18eb25eb..cdaf5a91 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,8 @@
+
+
+ android:label="@string/activity_label_about" />
+
+
+ if (android.os.Build.VERSION.SDK_INT >= 30) {
+ val bars = insets.getInsets(
+ WindowInsets.Type.systemBars() or
+ WindowInsets.Type.displayCutout(),
+ )
+ view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
+ } else {
+ @Suppress("DEPRECATION")
+ view.setPadding(
+ insets.systemWindowInsetLeft,
+ insets.systemWindowInsetTop,
+ insets.systemWindowInsetRight,
+ insets.systemWindowInsetBottom,
+ )
+ }
+ insets
+ }
+ addView(content)
+ },
+ )
+ }
+
+ private fun diagnosticsCard(): LinearLayout {
+ val safeMode = CrashGuard.isSafeMode(this)
+ val lastReport = CrashGuard.lastReport(this)
+ val card = SetupUi.infoCard(
+ this,
+ title = "诊断",
+ body = when {
+ safeMode -> "输入法刚才多次异常退出,已临时关闭原生词库和语音预加载;约 10 分钟后自动恢复,也可以现在退出。"
+ lastReport != null -> "最近一次异常:" + lastReport.lineSequence().first().substringAfter("| ").substringBefore(" | thread")
+ else -> "没有异常记录。"
+ } + "\n诊断信息只含异常类型和代码位置,不含任何输入内容;只有你点“复制”才会离开这里。",
+ iconRes = R.drawable.ic_pref_info,
+ )
+ if (lastReport == null && !safeMode) return card
+ val row = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ if (lastReport != null) {
+ addView(
+ SetupUi.secondaryButton(this@AboutActivity, "复制诊断信息") {
+ val clipboard = getSystemService(android.content.ClipboardManager::class.java)
+ clipboard.setPrimaryClip(android.content.ClipData.newPlainText("openIME diagnostics", lastReport))
+ Toast.makeText(this@AboutActivity, "已复制", Toast.LENGTH_SHORT).show()
+ },
+ LinearLayout.LayoutParams(0, dp(44), 1f).apply { marginEnd = dp(ImeSpacingTokens.SM_DP) },
+ )
+ }
+ if (safeMode) {
+ addView(
+ SetupUi.primaryButton(this@AboutActivity, "退出安全模式") {
+ CrashGuard.clearHistory(this@AboutActivity)
+ Toast.makeText(this@AboutActivity, "下次打开键盘时恢复完整功能", Toast.LENGTH_SHORT).show()
+ recreate()
+ },
+ LinearLayout.LayoutParams(0, dp(44), 1f),
+ )
+ }
+ }
+ card.addView(
+ row,
+ LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(44)).apply {
+ topMargin = dp(12)
+ marginStart = dp(44)
+ },
+ )
+ return card
+ }
+
+ @Suppress("DEPRECATION")
+ private fun versionName(): String =
+ packageManager.getPackageInfo(packageName, 0).versionName.orEmpty()
+ .ifBlank { "未知版本" }
+
+ private fun wrap() = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ )
+
+ private fun dp(value: Int): Int = SetupUi.dp(this, value)
+}
diff --git a/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt b/app/src/main/java/llc/slacker/openime/DataManagementActivity.kt
similarity index 67%
rename from app/src/main/java/llc/slacker/openime/AboutDataActivity.kt
rename to app/src/main/java/llc/slacker/openime/DataManagementActivity.kt
index b93a741a..d31c4506 100644
--- a/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt
+++ b/app/src/main/java/llc/slacker/openime/DataManagementActivity.kt
@@ -8,7 +8,6 @@ import android.os.Bundle
import android.view.WindowInsets
import android.widget.LinearLayout
import android.widget.ScrollView
-import android.widget.TextView
import android.widget.Toast
import llc.slacker.openime.core.CrashGuard
import llc.slacker.openime.data.UserDataArchive
@@ -17,13 +16,15 @@ import llc.slacker.openime.data.UserDataImportPreview
import llc.slacker.openime.data.UserDataRepository
import llc.slacker.openime.hotword.HotwordPacksActivity
import llc.slacker.openime.setup.SetupUi
-import llc.slacker.openime.theme.ImeDrawableFactory
import llc.slacker.openime.theme.ImeGeometryTokens
import llc.slacker.openime.theme.ImeSpacingTokens
-import llc.slacker.openime.theme.ImeTypographyTokens
import java.io.File
-class AboutDataActivity : Activity() {
+/**
+ * 数据管理: export / import of user data, the voice word lists, and what an
+ * uninstall removes. Version, privacy and diagnostics live in [AboutActivity].
+ */
+class DataManagementActivity : Activity() {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(SetupUi.appearanceContext(newBase))
}
@@ -43,8 +44,8 @@ class AboutDataActivity : Activity() {
)
addView(
SetupUi.activityTopBar(
- context = this@AboutDataActivity,
- title = "关于与数据",
+ context = this@DataManagementActivity,
+ title = "数据管理",
onBack = ::finish,
),
LinearLayout.LayoutParams(
@@ -52,22 +53,16 @@ class AboutDataActivity : Activity() {
dp(ImeGeometryTokens.TOP_BAR_HEIGHT_DP),
).apply { marginStart = -dp(16); marginEnd = -dp(16) },
)
- addView(
- infoCard(
- title = "隐私",
- body = "本应用不含联网权限,数据只存在本机。",
- ),
- wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) },
- )
val dataCard = infoCard(
title = "用户数据",
body = "JSON 导出包含常用语、自定义符号、备用用户词条和设置项;剪贴板历史不导出。Rime 自动学习词库在已加载时一并导出,并在导入时按词库合并。",
+ iconRes = R.drawable.ic_pref_data,
)
- addView(dataCard, wrap().apply { topMargin = dp(12) })
- val actions = LinearLayout(this@AboutDataActivity).apply {
+ addView(dataCard, wrap().apply { topMargin = dp(ImeSpacingTokens.SM_DP) })
+ val actions = LinearLayout(this@DataManagementActivity).apply {
orientation = LinearLayout.HORIZONTAL
addView(
- SetupUi.primaryButton(this@AboutDataActivity, "导出") {
+ SetupUi.primaryButton(this@DataManagementActivity, "导出") {
requestExportDocument()
},
LinearLayout.LayoutParams(
@@ -77,7 +72,7 @@ class AboutDataActivity : Activity() {
).apply { marginEnd = dp(ImeSpacingTokens.SM_DP) },
)
addView(
- SetupUi.secondaryButton(this@AboutDataActivity, "导入") {
+ SetupUi.secondaryButton(this@DataManagementActivity, "导入") {
requestImportDocument()
},
LinearLayout.LayoutParams(
@@ -97,11 +92,12 @@ class AboutDataActivity : Activity() {
val hotwordCard = infoCard(
title = "语音词表",
body = "内置游戏、科技和应用词表,也可以导入自己的词表文件。识别后把同音的词改成词表里的写法,不联网。",
+ iconRes = R.drawable.ic_pref_waveform,
)
addView(hotwordCard, wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) })
hotwordCard.addView(
- SetupUi.secondaryButton(this@AboutDataActivity, "管理词表") {
- startActivity(Intent(this@AboutDataActivity, HotwordPacksActivity::class.java))
+ SetupUi.secondaryButton(this@DataManagementActivity, "管理词表") {
+ startActivity(Intent(this@DataManagementActivity, HotwordPacksActivity::class.java))
},
LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(44))
.apply { topMargin = dp(12); marginStart = dp(44) },
@@ -110,53 +106,10 @@ class AboutDataActivity : Activity() {
infoCard(
title = "卸载前",
body = "卸载会清除本机全部数据,包括学习的用户词库。卸载前可先导出用户数据。",
+ iconRes = R.drawable.ic_pref_info,
),
wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) },
)
- val safeMode = CrashGuard.isSafeMode(this@AboutDataActivity)
- val lastReport = CrashGuard.lastReport(this@AboutDataActivity)
- val diagnostics = infoCard(
- title = "诊断",
- body = when {
- safeMode -> "输入法刚才多次异常退出,已临时关闭原生词库和语音预加载;约 10 分钟后自动恢复,也可以现在退出。"
- lastReport != null -> "最近一次异常:" + lastReport.lineSequence().first().substringAfter("| ").substringBefore(" | thread")
- else -> "没有异常记录。"
- } + "\n诊断信息只含异常类型和代码位置,不含任何输入内容;只有你点“复制”才会离开这里。",
- )
- addView(diagnostics, wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) })
- if (lastReport != null || safeMode) {
- val row = LinearLayout(this@AboutDataActivity).apply {
- orientation = LinearLayout.HORIZONTAL
- if (lastReport != null) {
- addView(
- SetupUi.secondaryButton(this@AboutDataActivity, "复制诊断信息") {
- val clipboard = getSystemService(android.content.ClipboardManager::class.java)
- clipboard.setPrimaryClip(android.content.ClipData.newPlainText("openIME diagnostics", lastReport))
- Toast.makeText(this@AboutDataActivity, "已复制", Toast.LENGTH_SHORT).show()
- },
- LinearLayout.LayoutParams(0, dp(44), 1f).apply { marginEnd = dp(ImeSpacingTokens.SM_DP) },
- )
- }
- if (safeMode) {
- addView(
- SetupUi.primaryButton(this@AboutDataActivity, "退出安全模式") {
- CrashGuard.clearHistory(this@AboutDataActivity)
- Toast.makeText(this@AboutDataActivity, "下次打开键盘时恢复完整功能", Toast.LENGTH_SHORT).show()
- recreate()
- },
- LinearLayout.LayoutParams(0, dp(44), 1f),
- )
- }
- }
- diagnostics.addView(
- row,
- LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(44)).apply { topMargin = dp(12); marginStart = dp(44) },
- )
- }
- addView(TextView(this@AboutDataActivity).apply {
- text = "openIME · 版本 " + versionName(); textSize = ImeTypographyTokens.SMALL_SP; gravity = android.view.Gravity.CENTER
- setTextColor(getColor(R.color.setup_body))
- }, wrap().apply { topMargin = dp(18) })
}
setContentView(
@@ -384,45 +337,8 @@ class AboutDataActivity : Activity() {
}
}
- private fun infoCard(title: String, body: String): LinearLayout =
- LinearLayout(this).apply {
- orientation = LinearLayout.VERTICAL
- setPadding(
- dp(ImeSpacingTokens.LG_DP),
- dp(ImeSpacingTokens.LG_DP),
- dp(ImeSpacingTokens.LG_DP),
- dp(ImeSpacingTokens.LG_DP),
- )
- background = SetupUi.rounded(
- getColor(R.color.setup_surface),
- dp(ImeGeometryTokens.CARD_RADIUS_DP).toFloat(),
- getColor(R.color.setup_input_line),
- )
- addView(LinearLayout(this@AboutDataActivity).apply {
- orientation = LinearLayout.HORIZONTAL
- addView(android.widget.ImageView(this@AboutDataActivity).apply {
- setImageResource(if (title == "用户数据") R.drawable.ic_download else if (title == "隐私") R.drawable.ic_shield else R.drawable.ic_info)
- imageTintList = android.content.res.ColorStateList.valueOf(SetupUi.accent(this@AboutDataActivity))
- scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
- setPadding(dp(8), dp(8), dp(8), dp(8))
- background = SetupUi.rounded(ImeDrawableFactory.blend(SetupUi.accent(this@AboutDataActivity), getColor(R.color.setup_surface), 0.14f), dp(8).toFloat())
- }, LinearLayout.LayoutParams(dp(32), dp(32)).apply { marginEnd = dp(12) })
- addView(LinearLayout(this@AboutDataActivity).apply {
- orientation = LinearLayout.VERTICAL
- addView(TextView(this@AboutDataActivity).apply {
- text = title; textSize = ImeTypographyTokens.BODY_SP; setTextColor(getColor(R.color.setup_title)); typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
- }, wrap())
- addView(TextView(this@AboutDataActivity).apply {
- text = body; textSize = ImeTypographyTokens.BODY_SP; setTextColor(getColor(R.color.setup_body)); setLineSpacing(0f, 1.3f)
- }, wrap().apply { topMargin = dp(4) })
- }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
- }, wrap())
- }
-
- @Suppress("DEPRECATION")
- private fun versionName(): String =
- packageManager.getPackageInfo(packageName, 0).versionName.orEmpty()
- .ifBlank { "未知版本" }
+ private fun infoCard(title: String, body: String, iconRes: Int): LinearLayout =
+ SetupUi.infoCard(this, title, body, iconRes)
private fun showError(message: String) {
val dialog = AlertDialog.Builder(this)
diff --git a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt
index 499fac40..0e3d47c0 100644
--- a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt
+++ b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt
@@ -200,6 +200,9 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener {
ImeSettingsRepository.saveFuzzy(this, enabled)
refreshLiveIme()
}
+ override fun onHapticStrengthChanged(percent: Int) {
+ refreshLiveIme()
+ }
override fun onKeyboardHeightChanged(percent: Int) {
ImeSettingsRepository.saveKeyboardHeightPercent(this, percent)
refreshLiveIme()
@@ -210,7 +213,11 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener {
refreshLiveIme()
}
- override fun onOpenAboutData() {
- startActivity(Intent(this, AboutDataActivity::class.java))
+ override fun onOpenAbout() {
+ startActivity(Intent(this, AboutActivity::class.java))
+ }
+
+ override fun onOpenDataManagement() {
+ startActivity(Intent(this, DataManagementActivity::class.java))
}
}
diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt
index c35f8a8d..30cc6935 100644
--- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt
+++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt
@@ -343,10 +343,6 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
super.onStartInput(attribute, restarting)
- if (!restarting) {
- gateway.discardClearUndo()
- keyboardView?.hideClearUndo()
- }
reloadPersistedSettings()
voiceCorrectionTracker.clear()
val previousRimeInputs = candidateQueries.activeInputs
@@ -847,41 +843,6 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
voiceComposing = false
state = state.copy(voiceState = VoiceUiState())
keyboardView?.renderState(state)
- if (cleared && gateway.hasClearUndo()) {
- keyboardView?.showClearUndo()
- } else {
- keyboardView?.hideClearUndo()
- }
- }
-
- override fun onUndoClear(): Boolean {
- if (state.passwordField) return false
- val restored = gateway.restoreLastClear()
- if (!restored) {
- showTextEditFeedback("没有可撤回的清空内容")
- return false
- }
- voiceCorrectionTracker.clear()
- voiceComposing = false
- state = state.copy(
- composition = "",
- candidates = emptyList(),
- expandedCandidates = emptyList(),
- voiceState = VoiceUiState(),
- )
- lastComposition = ""
- renderedCandidateSnapshot = null
- keyboardView?.clearAssociationCandidates()
- keyboardView?.hideClearUndo()
- keyboardView?.renderState(state)
- return true
- }
-
- override fun hasClearUndo(): Boolean = ::gateway.isInitialized && gateway.hasClearUndo()
-
- override fun onUndoClearExpired() {
- gateway.discardClearUndo()
- keyboardView?.hideClearUndo()
}
override fun onFloatingKeyboardChanged(floating: Boolean) {
@@ -1828,11 +1789,12 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
::rime.isInitialized &&
rime.importUserDictionaries(sourceDir, dictionaries, onComplete)
- override fun onOpenAboutData() {
- startActivity(
- Intent(this, AboutDataActivity::class.java)
- .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
- )
+ override fun onOpenAbout() {
+ startActivity(Intent(this, AboutActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }
+
+ override fun onOpenDataManagement() {
+ startActivity(Intent(this, DataManagementActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
internal companion object {
diff --git a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt
index 7cda51ec..a443efdb 100644
--- a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt
+++ b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt
@@ -13,6 +13,7 @@ object ImeSettingsRepository {
private const val KEY_APPEARANCE = "appearance"
private const val KEY_SOUND = "sound"
private const val KEY_HAPTIC = "haptic"
+ private const val KEY_HAPTIC_STRENGTH = "haptic_strength_percent"
private const val KEY_POPUP = "popup"
private const val KEY_FUZZY = "fuzzy"
private const val KEY_SWIPE_UP_DIGITS = "swipe_up_digits"
@@ -126,6 +127,17 @@ object ImeSettingsRepository {
.edit().putBoolean(KEY_HAPTIC, enabled).apply()
}
+ /** Key-click strength, 10–100% of the full click. */
+ fun loadHapticStrengthPercent(context: Context): Int =
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .getInt(KEY_HAPTIC_STRENGTH, 100)
+ .coerceIn(10, 100)
+
+ fun saveHapticStrengthPercent(context: Context, percent: Int) {
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .edit().putInt(KEY_HAPTIC_STRENGTH, percent.coerceIn(10, 100)).apply()
+ }
+
fun loadSwipeUpDigits(context: Context): Boolean =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getBoolean(KEY_SWIPE_UP_DIGITS, true)
diff --git a/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt
index fd0d2723..77ef2ede 100644
--- a/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt
+++ b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt
@@ -42,6 +42,7 @@ internal data class ArchiveSettings(
val emojiAssociation: Boolean = true,
val voiceStripFillers: Boolean = true,
val voicePunctuationAsSpace: Boolean = false,
+ val hapticStrengthPercent: Int = 100,
)
internal data class RimeUserDictionaryArchive(
@@ -174,6 +175,7 @@ internal object UserDataArchiveCodec {
.put("appearance", value.appearance)
.put("sound", value.sound)
.put("haptic", value.haptic)
+ .put("haptic_strength_percent", value.hapticStrengthPercent)
.put("popup", value.popup)
.put("fuzzy", value.fuzzy)
.put("swipe_up_digits", value.swipeUpDigits)
@@ -192,6 +194,7 @@ internal object UserDataArchiveCodec {
appearance = value.optString("appearance", ImeAppearance.SYSTEM.name),
sound = value.optBoolean("sound", true),
haptic = value.optBoolean("haptic", true),
+ hapticStrengthPercent = value.optInt("haptic_strength_percent", 100).coerceIn(10, 100),
popup = value.optBoolean("popup", false),
fuzzy = value.optBoolean("fuzzy", false),
swipeUpDigits = value.optBoolean("swipe_up_digits", true),
@@ -321,6 +324,7 @@ internal object UserDataRepository {
appearance = ImeSettingsRepository.loadAppearance(context).name,
sound = ImeSettingsRepository.loadSound(context),
haptic = ImeSettingsRepository.loadHaptic(context),
+ hapticStrengthPercent = ImeSettingsRepository.loadHapticStrengthPercent(context),
popup = ImeSettingsRepository.loadPopup(context),
fuzzy = ImeSettingsRepository.loadFuzzy(context),
swipeUpDigits = ImeSettingsRepository.loadSwipeUpDigits(context),
@@ -403,6 +407,7 @@ internal object UserDataRepository {
)
ImeSettingsRepository.saveSound(context, value.sound)
ImeSettingsRepository.saveHaptic(context, value.haptic)
+ ImeSettingsRepository.saveHapticStrengthPercent(context, value.hapticStrengthPercent)
ImeSettingsRepository.savePopup(context, value.popup)
ImeSettingsRepository.saveFuzzy(context, value.fuzzy)
ImeSettingsRepository.saveSwipeUpDigits(context, value.swipeUpDigits)
diff --git a/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt
index 4dc24b75..78ecac7d 100644
--- a/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt
+++ b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt
@@ -45,7 +45,6 @@ class InputConnectionGateway(
private val context: Context?,
private val connection: () -> InputConnection?,
private val isPassword: () -> Boolean = { false },
- private val nowMs: () -> Long = { System.nanoTime() / 1_000_000L },
/**
* Editors with no text type (terminals, games, remote desktops: TYPE_NULL).
* Their InputConnection is usually Android's BaseInputConnection in dummy
@@ -75,38 +74,8 @@ class InputConnectionGateway(
}
}
- private data class ClearUndoSnapshot(
- val text: String,
- val selectionStart: Int,
- val selectionEnd: Int,
- val connection: InputConnection,
- val expiresAtMs: Long,
- )
-
- private var clearUndoSnapshot: ClearUndoSnapshot? = null
-
- private fun invalidateClearUndo() {
- clearUndoSnapshot = null
- }
-
- fun discardClearUndo() {
- invalidateClearUndo()
- }
-
- fun hasClearUndo(): Boolean {
- if (isPassword()) return false
- val snapshot = clearUndoSnapshot ?: return false
- val current = connection() ?: return false
- if (snapshot.connection !== current || nowMs() > snapshot.expiresAtMs) {
- invalidateClearUndo()
- return false
- }
- return true
- }
-
fun commitText(text: String) {
if (text.isEmpty()) return
- invalidateClearUndo()
val ic = connection() ?: return
if (isRawKeys() && typeAsKeyEvents(ic, text)) return
// One Binder transaction carries about 1 MB: committing a huge paste or
@@ -133,7 +102,6 @@ class InputConnectionGateway(
fun setComposingText(text: String) {
if (isPassword()) return
- if (text.isNotEmpty()) invalidateClearUndo()
val ic = connection() ?: return
if (text.isEmpty()) {
ic.finishComposingText()
@@ -190,7 +158,6 @@ class InputConnectionGateway(
}
fun deleteBackwards() {
- invalidateClearUndo()
val ic = connection() ?: return
if (isRawKeys()) {
sendKeyDownUp(ic, KeyEvent.KEYCODE_DEL)
@@ -220,7 +187,6 @@ class InputConnectionGateway(
/** Delete the active selection without falling back to one-character delete. */
fun deleteSelection(): Boolean {
- invalidateClearUndo()
val ic = connection() ?: return false
if (isPassword()) {
if (knownSelectionStart >= 0 && knownSelectionEnd >= 0 && knownSelectionStart != knownSelectionEnd) {
@@ -280,7 +246,6 @@ class InputConnectionGateway(
}
fun deleteForwards() {
- invalidateClearUndo()
val ic = connection() ?: return
if (isRawKeys()) {
sendKeyDownUp(ic, KeyEvent.KEYCODE_FORWARD_DEL)
@@ -325,7 +290,6 @@ class InputConnectionGateway(
fun clearAllText(): Boolean {
if (isPassword()) return false
val ic = connection() ?: return false
- clearUndoSnapshot = null
ic.beginBatchEdit()
return try {
val originalSelection = selectionBeforeDestructiveSelectAll(ic)
@@ -339,7 +303,6 @@ class InputConnectionGateway(
restoreSelectionAfterFailedClear(ic, originalSelection)
} else {
ic.finishComposingText()
- rememberClearUndo(selected, originalSelection, ic)
}
return cleared
}
@@ -358,7 +321,6 @@ class InputConnectionGateway(
restoreSelectionAfterFailedClear(ic, originalSelection)
} else {
ic.finishComposingText()
- rememberClearUndo(selectedWindow.text, originalSelection, ic)
}
return cleared
}
@@ -383,7 +345,6 @@ class InputConnectionGateway(
restoreSelectionAfterFailedClear(ic, originalSelection)
} else {
ic.finishComposingText()
- rememberClearUndo(window.text, originalSelection, ic)
}
cleared
} finally {
@@ -396,7 +357,7 @@ class InputConnectionGateway(
*
* Editors that implement neither select-all nor a complete ExtractedText
* (custom canvas, Compose and web fields) still answer these two queries.
- * Everything is captured first, so the clear can be undone, and the
+ * Everything is captured first, so a failed round can put it back, and the
* deletion repeats until the editor itself reports that nothing is left.
* That makes an editor which silently caps its answers safe: it is cleared
* in several rounds instead of being left half full.
@@ -440,101 +401,14 @@ class InputConnectionGateway(
}
ic.finishComposingText()
- val head = beforeParts.asReversed().joinToString("")
- val full = head + selected + afterParts.joinToString("")
- rememberClearUndo(
- text = full,
- selection = SelectionSnapshot.Absolute(head.length, head.length + selected.length),
- ic = ic,
- )
return true
}
- /**
- * Restore only the most recent successful full-document clear. Any normal
- * edit invalidates the snapshot, and restoration is rejected unless the
- * target document is still empty.
- */
- fun restoreLastClear(): Boolean {
- if (isPassword()) return false
- val snapshot = clearUndoSnapshot ?: return false
- val ic = connection() ?: return false
- if (snapshot.connection !== ic || nowMs() > snapshot.expiresAtMs) {
- invalidateClearUndo()
- return false
- }
-
- val window = extractedWindow(ic)
- val documentEmpty = if (window?.isCompleteDocument == true) {
- window.text.isEmpty()
- } else {
- val before = runCatching { ic.getTextBeforeCursor(1, 0)?.isEmpty() == true }
- .getOrDefault(false)
- val after = runCatching { ic.getTextAfterCursor(1, 0)?.isEmpty() == true }
- .getOrDefault(false)
- val selected = runCatching { ic.getSelectedText(0)?.isEmpty() != false }
- .getOrDefault(false)
- before && after && selected
- }
- if (!documentEmpty) {
- invalidateClearUndo()
- return false
- }
-
- ic.beginBatchEdit()
- return try {
- val committed = runCatching { ic.commitText(snapshot.text, 1) }.getOrDefault(false)
- if (!committed) return false
- ic.finishComposingText()
- val start = snapshot.selectionStart.coerceIn(0, snapshot.text.length)
- val end = snapshot.selectionEnd.coerceIn(0, snapshot.text.length)
- runCatching { ic.setSelection(start, end) }
- knownSelectionStart = start
- knownSelectionEnd = end
- invalidateClearUndo()
- true
- } finally {
- ic.endBatchEdit()
- }
- }
-
- private fun rememberClearUndo(
- text: String,
- selection: SelectionSnapshot?,
- ic: InputConnection,
- ) {
- if (text.isEmpty()) return
- val start: Int
- val end: Int
- when (selection) {
- is SelectionSnapshot.Absolute -> {
- start = selection.start
- end = selection.end
- }
- is SelectionSnapshot.Relative -> {
- start = selection.cursor
- end = selection.cursor
- }
- null -> {
- start = text.length
- end = text.length
- }
- }
- clearUndoSnapshot = ClearUndoSnapshot(
- text = text,
- selectionStart = start.coerceIn(0, text.length),
- selectionEnd = end.coerceIn(0, text.length),
- connection = ic,
- expiresAtMs = nowMs() + CLEAR_UNDO_TIMEOUT_MS,
- )
- }
-
fun performEditorAction(action: Int) {
connection()?.performEditorAction(action)
}
fun sendKeyDownUp(keyCode: Int) {
- invalidateClearUndo()
val ic = connection() ?: return
sendKeyDownUp(ic, keyCode)
}
@@ -740,7 +614,6 @@ class InputConnectionGateway(
clip.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(safeContext)?.toString().orEmpty()
}.getOrDefault("")
if (text.isEmpty()) return ""
- invalidateClearUndo()
val committed = connection()?.commitText(text, 1) == true
if (!committed) return ""
onPasted(clip)
@@ -748,7 +621,6 @@ class InputConnectionGateway(
}
fun commitContent(info: InputContentInfo): Boolean {
- invalidateClearUndo()
return connection()?.commitContent(info, 0, null) == true
}
@@ -853,7 +725,6 @@ class InputConnectionGateway(
private companion object {
const val FALLBACK_WINDOW_CHARS = 8_192
- const val CLEAR_UNDO_TIMEOUT_MS = 5_000L
const val SURROUNDING_CHUNK = 100_000
const val MAX_SURROUNDING_ROUNDS = 8
}
diff --git a/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt b/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt
index 01bda39c..f09f7076 100644
--- a/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt
+++ b/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt
@@ -16,30 +16,18 @@ internal enum class GestureHint {
/** Far enough: releasing now clears the whole field. */
CLEAR_ARMED,
-
- /** Moving down while a clear can still be undone. */
- UNDO_PREVIEW,
-
- /** Far enough: releasing now restores the cleared text. */
- UNDO_ARMED,
}
/**
- * Owns the held-backspace gesture: repeat delete, upward clear-all, and the
- * one-shot downward restore that is available immediately after a successful
- * clear gesture. Editor mutations remain callbacks.
+ * Owns the held-backspace gesture: repeat delete and upward clear-all. A clear
+ * is final; there is no downward restore. Editor mutations remain callbacks.
*
- * Feedback is a single [GestureHint] stream. Clear and restore used to be two
- * unrelated popups (a red pill and a white card) plus a tiny label inside the
- * key; both are now the same bubble with a different label and fill.
+ * Feedback is a single [GestureHint] stream driving one bubble.
*/
internal class BackspaceGestureController(
private val toPx: (Int) -> Int,
private val onDeleteOne: () -> Unit,
private val onClearAll: () -> Unit,
- private val onUndoClear: () -> Boolean,
- /** The editor-side truth: a clear can be undone only while its snapshot is alive. */
- private val hasUndoSnapshot: () -> Boolean,
private val onPressFeedback: () -> Unit,
private val onHapticFeedback: () -> Unit,
private val onGestureHint: (View, GestureHint) -> Unit,
@@ -55,8 +43,6 @@ internal class BackspaceGestureController(
private set
private var clearArmed = false
- private var undoArmed = false
- private var undoAvailable = false // re-read from the editor on every press
private var repeatStarted = false
private var repeatSuspended = false
private var hint = GestureHint.NONE
@@ -67,7 +53,7 @@ internal class BackspaceGestureController(
private val repeatAction = object : Runnable {
override fun run() {
- if (!active || clearArmed || undoArmed) return
+ if (!active || clearArmed) return
repeatStarted = true
onDeleteOne()
handler.postDelayed(this, REPEAT_INTERVAL_MS)
@@ -84,11 +70,9 @@ internal class BackspaceGestureController(
handler.removeCallbacks(repeatAction)
repeatStartAction?.let(handler::removeCallbacks)
- undoAvailable = hasUndoSnapshot()
- Log.d(TAG, "bs begin x=$rawX y=$rawY pointer=$pointerId undoAvailable=$undoAvailable")
+ Log.d(TAG, "bs begin x=$rawX y=$rawY pointer=$pointerId")
active = true
clearArmed = false
- undoArmed = false
repeatStarted = false
repeatSuspended = false
hint = GestureHint.NONE
@@ -103,7 +87,7 @@ internal class BackspaceGestureController(
onPressFeedback()
val startRepeat = Runnable {
- if (active && !clearArmed && !undoArmed) repeatAction.run()
+ if (active && !clearArmed) repeatAction.run()
}
repeatStartAction = startRepeat
handler.postDelayed(startRepeat, longPressTimeoutMs)
@@ -112,7 +96,6 @@ internal class BackspaceGestureController(
fun update(rawX: Float, rawY: Float) {
if (!active) return
val upward = startY - rawY
- val downward = rawY - startY
val horizontal = abs(rawX - startX)
val armDistance = toPx(CLEAR_ARM_DP)
val previewDistance = toPx(PREVIEW_DP)
@@ -120,14 +103,13 @@ internal class BackspaceGestureController(
// Suspend repeat-delete as soon as the gesture clearly becomes a
// vertical command. Otherwise a slow swipe could mutate text before
- // clear/restore is armed.
- val verticalCommand =
- onAxis && (upward >= previewDistance || (undoAvailable && downward >= previewDistance))
+ // clear is armed.
+ val verticalCommand = onAxis && upward >= previewDistance
if (verticalCommand) {
repeatSuspended = true
repeatStartAction?.let(handler::removeCallbacks)
handler.removeCallbacks(repeatAction)
- } else if (repeatSuspended && !clearArmed && !undoArmed) {
+ } else if (repeatSuspended && !clearArmed) {
repeatSuspended = false
repeatStartAction?.let {
handler.postDelayed(
@@ -137,46 +119,25 @@ internal class BackspaceGestureController(
}
}
- // Restore is only offered right after a clear; the two commands are
- // mutually exclusive, with hysteresis so a wobble cannot flip them.
- val shouldUndo = undoAvailable && if (undoArmed) {
- downward > toPx(RELEASE_DP) && horizontal <= toPx(HYSTERESIS_DRIFT_DP)
+ // Hysteresis so a wobble near the threshold cannot flip the state.
+ val shouldClear = if (clearArmed) {
+ upward > toPx(RELEASE_DP) && horizontal <= toPx(HYSTERESIS_DRIFT_DP)
} else {
- downward >= armDistance && onAxis
+ upward >= armDistance && onAxis
}
- if (shouldUndo != undoArmed) {
- Log.d(TAG, "bs undoArmed=$shouldUndo down=${downward.toInt()} h=${horizontal.toInt()}")
- undoArmed = shouldUndo
- if (undoArmed) {
- clearArmed = false
+ if (shouldClear != clearArmed) {
+ Log.d(TAG, "bs clearArmed=$shouldClear up=${upward.toInt()} h=${horizontal.toInt()}")
+ clearArmed = shouldClear
+ if (clearArmed) {
repeatStartAction?.let(handler::removeCallbacks)
handler.removeCallbacks(repeatAction)
}
onHapticFeedback()
}
- if (!undoArmed) {
- val shouldClear = if (clearArmed) {
- upward > toPx(RELEASE_DP) && horizontal <= toPx(HYSTERESIS_DRIFT_DP)
- } else {
- upward >= armDistance && onAxis
- }
- if (shouldClear != clearArmed) {
- Log.d(TAG, "bs clearArmed=$shouldClear up=${upward.toInt()} h=${horizontal.toInt()}")
- clearArmed = shouldClear
- if (clearArmed) {
- repeatStartAction?.let(handler::removeCallbacks)
- handler.removeCallbacks(repeatAction)
- }
- onHapticFeedback()
- }
- }
-
setHint(
when {
- undoArmed -> GestureHint.UNDO_ARMED
clearArmed -> GestureHint.CLEAR_ARMED
- undoAvailable && onAxis && downward >= previewDistance -> GestureHint.UNDO_PREVIEW
onAxis && upward >= previewDistance -> GestureHint.CLEAR_PREVIEW
else -> GestureHint.NONE
},
@@ -191,10 +152,9 @@ internal class BackspaceGestureController(
fun finish(commit: Boolean, rawX: Float? = null, rawY: Float? = null) {
if (!active) return
if (commit && rawX != null && rawY != null) update(rawX, rawY)
- Log.d(TAG, "bs finish commit=$commit clearArmed=$clearArmed undoArmed=$undoArmed repeat=$repeatStarted")
- val restoreClear = commit && undoArmed
+ Log.d(TAG, "bs finish commit=$commit clearArmed=$clearArmed repeat=$repeatStarted")
val clearAll = commit && clearArmed
- val deleteOnce = commit && !clearArmed && !undoArmed && !repeatStarted
+ val deleteOnce = commit && !clearArmed && !repeatStarted
handler.removeCallbacks(repeatAction)
repeatStartAction?.let(handler::removeCallbacks)
@@ -208,18 +168,12 @@ internal class BackspaceGestureController(
active = false
pointerId = -1
clearArmed = false
- undoArmed = false
repeatStarted = false
repeatSuspended = false
anchor = null
onHidePopup()
when {
- restoreClear -> {
- onHapticFeedback()
- onUndoClear()
- // The gateway validates the one-shot snapshot and consumes it.
- }
clearAll -> {
onHapticFeedback()
onClearAll()
@@ -235,7 +189,6 @@ internal class BackspaceGestureController(
handler.removeCallbacksAndMessages(null)
repeatStartAction = null
pointerId = -1
- undoAvailable = false
}
private fun setHint(next: GestureHint) {
@@ -249,7 +202,7 @@ internal class BackspaceGestureController(
private const val TAG = "OpenIme"
/**
- * Travel from the press point that arms clear (up) or restore (down).
+ * Upward travel from the press point that arms clear.
* Half a key height past its edge: a normal thumb flick reaches it, a
* wobble while holding repeat-delete does not. It used to be 56dp, more
* than a full key above the key centre, so ordinary flicks fell through
diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt
index a3659982..af16741e 100644
--- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt
+++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt
@@ -13,7 +13,6 @@ import android.os.SystemClock
import android.text.TextUtils
import android.util.Log
import android.view.Gravity
-import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.SoundEffectConstants
import android.view.View
@@ -80,11 +79,6 @@ open class ImeKeyboardView(
fun onCharacter(char: String)
fun onBackspace()
fun onClearAll()
- fun onUndoClear(): Boolean = false
- fun onUndoClearExpired() {}
-
- /** Whether the last clear-all can still be restored (drives the swipe-down hint). */
- fun hasClearUndo(): Boolean = false
fun onSpace()
fun onFloatingKeyboardChanged(floating: Boolean)
fun onFloatingKeyboardDragged(deltaX: Float, deltaY: Float)
@@ -128,8 +122,10 @@ open class ImeKeyboardView(
fun onPopupChanged(enabled: Boolean)
fun onFuzzyChanged(enabled: Boolean)
fun onKeyboardHeightChanged(percent: Int) {}
+ fun onHapticStrengthChanged(percent: Int) {}
fun onFloatingStyleChanged(widthPercent: Int, opacityPercent: Int) {}
- fun onOpenAboutData() {}
+ fun onOpenAbout() {}
+ fun onOpenDataManagement() {}
}
/** Visual class marker for white keys (nine/digits grid). */
@@ -145,13 +141,11 @@ open class ImeKeyboardView(
/** How often a deferred row rebuild re-checks whether the press ended. */
private const val ROW_REBUILD_POLL_MS = 40L
- /** KEYBOARD_TAP needs API 27; API 26 falls back to the virtual-key click. */
- private val KEY_TAP_HAPTIC =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
- HapticFeedbackConstants.KEYBOARD_TAP
- } else {
- HapticFeedbackConstants.VIRTUAL_KEY
- }
+ /** Slider drags preview the strength at most this often. */
+ private const val STRENGTH_PREVIEW_INTERVAL_MS = 80L
+
+ /** playSoundEffect's "use the default UI click volume" value. */
+ private const val KEY_CLICK_DEFAULT_VOLUME = -1f
}
@@ -160,8 +154,6 @@ open class ImeKeyboardView(
toPx = ::dp,
onDeleteOne = ::performBackspaceOnce,
onClearAll = listener::onClearAll,
- onUndoClear = listener::onUndoClear,
- hasUndoSnapshot = listener::hasClearUndo,
onPressFeedback = ::feedback,
onHapticFeedback = ::hapticFeedback,
onGestureHint = { anchor, hint -> keyPopupController.showGestureHint(anchor, hint) },
@@ -350,6 +342,10 @@ open class ImeKeyboardView(
private var shiftState = ShiftState.LOWERCASE
private var soundEnabled = ImeSettingsRepository.loadSound(context)
private var hapticEnabled = ImeSettingsRepository.loadHaptic(context)
+ private val keyHaptics = KeyHaptics(context).apply {
+ strengthPercent = ImeSettingsRepository.loadHapticStrengthPercent(context)
+ }
+ private var lastStrengthPreviewMs = 0L
private val audioManager by lazy {
context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
}
@@ -565,6 +561,7 @@ open class ImeKeyboardView(
onModeSelected = { selected -> setMode(selected) },
onShowPanel = ::showPanel,
onEnableFloatingKeyboard = ::enableFloatingKeyboard,
+ onOpenDataManagement = listener::onOpenDataManagement,
onSymbolSelected = listener::onSymbolSelected,
onCharacter = listener::onCharacter,
onSpace = listener::onSpace,
@@ -631,6 +628,7 @@ open class ImeKeyboardView(
currentAppearance = { appearance },
currentSound = { soundEnabled },
currentHaptic = { hapticEnabled },
+ currentHapticStrengthPercent = { keyHaptics.strengthPercent },
currentPopup = { popupEnabled },
currentSwipeUpDigits = { ImeSettingsRepository.loadSwipeUpDigits(context) },
currentExtraToggle = ::onState,
@@ -645,9 +643,11 @@ open class ImeKeyboardView(
},
onToggleChanged = ::updateSettingToggle,
onKeyboardHeightChanged = ::setKeyboardHeightPercent,
+ onHapticStrengthChanged = ::setHapticStrengthPercent,
onFloatingStyleChanged = ::setFloatingStyle,
onShowFuzzySettings = { showPanel(Panel.FUZZY_SETTINGS) },
- onOpenAboutData = listener::onOpenAboutData,
+ onOpenAbout = listener::onOpenAbout,
+ onOpenDataManagement = listener::onOpenDataManagement,
onFeedback = ::feedback,
applyTheme = ::applyTheme,
onHierarchyRebuilt = ::onViewHierarchyRebuilt,
@@ -1150,8 +1150,7 @@ open class ImeKeyboardView(
updateTopZone(composition.text?.isNotEmpty() == true)
listener.onCandidateExpanded(open)
},
- onUndoClear = { if (!listener.onUndoClear()) listener.onTextEdit("undo") },
- onUndoClearExpired = listener::onUndoClearExpired,
+ onUndo = { listener.onTextEdit("undo") },
onAssociationDismiss = ::clearAssociationCandidates,
)
candidateBarController = CandidateBarController(
@@ -1560,6 +1559,20 @@ open class ImeKeyboardView(
listener.onFloatingStyleChanged(width, opacity)
}
+ /** Save the new strength and play one click at it, so the slider is felt while dragged. */
+ private fun setHapticStrengthPercent(percent: Int) {
+ val bounded = percent.coerceIn(KeyHaptics.MIN_STRENGTH, KeyHaptics.MAX_STRENGTH)
+ if (keyHaptics.strengthPercent == bounded) return
+ keyHaptics.strengthPercent = bounded
+ ImeSettingsRepository.saveHapticStrengthPercent(context, bounded)
+ listener.onHapticStrengthChanged(bounded)
+ val now = SystemClock.uptimeMillis()
+ if (now - lastStrengthPreviewMs >= STRENGTH_PREVIEW_INTERVAL_MS) {
+ lastStrengthPreviewMs = now
+ keyHaptics.click(this)
+ }
+ }
+
private fun setKeyboardHeightPercent(percent: Int) {
val bounded = percent.coerceIn(80, 120)
if (keyboardHeightPercent == bounded) return
@@ -1608,6 +1621,7 @@ open class ImeKeyboardView(
hapticEnabled = haptic
popupEnabled = popup
fuzzyEnabled = fuzzy
+ keyHaptics.strengthPercent = ImeSettingsRepository.loadHapticStrengthPercent(context)
if (heightChanged) {
keyboardHeightPercent = persistedHeight
renderedMode = null
@@ -1641,14 +1655,6 @@ open class ImeKeyboardView(
}
}
- fun showClearUndo() {
- if (::topZone.isInitialized) topZone.showUndoClear()
- }
-
- fun hideClearUndo(discardSnapshot: Boolean = false) {
- if (::topZone.isInitialized) topZone.hideUndoClear(discardSnapshot)
- }
-
fun setAppearance(newAppearance: ImeAppearance) {
if (appearance == newAppearance) return
appearance = newAppearance
@@ -2823,16 +2829,15 @@ open class ImeKeyboardView(
}
protected fun feedback() {
- // KEYBOARD_TAP is the platform's 10–20 ms key click; CLOCK_TICK is a
- // scroll texture and feels mushy on many actuators.
- if (hapticEnabled) performHapticFeedback(KEY_TAP_HAPTIC)
+ if (hapticEnabled) keyHaptics.click(this)
if (soundEnabled) {
- // View effects can be disabled by an IME host window even when the
- // app preference is on. Use the system keypress channel directly;
- // the view channel remains the fallback for standalone previews.
+ // playSoundEffect(effect) plays only when the system "touch sounds"
+ // setting is on, which most phones ship off, so the in-app switch
+ // did nothing. The volume overload skips that check (AOSP LatinIME
+ // does the same); -1 is the platform's default key-click volume.
runCatching {
if (audioManager != null) {
- audioManager?.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD)
+ audioManager?.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD, KEY_CLICK_DEFAULT_VOLUME)
} else {
isSoundEffectsEnabled = true
playSoundEffect(SoundEffectConstants.CLICK)
@@ -2843,8 +2848,8 @@ open class ImeKeyboardView(
/** Haptic-only confirmation (no key click sound), e.g. when voice arms. */
private fun hapticFeedback() {
- // A crisp click marks a gesture threshold; LONG_PRESS rings for too long.
- if (hapticEnabled) performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
+ // A gesture threshold gets the same crisp click; LONG_PRESS rings for too long.
+ if (hapticEnabled) keyHaptics.click(this)
}
private fun key(
@@ -2994,7 +2999,6 @@ open class ImeKeyboardView(
expandedPanel.setBackgroundColor(t.keyboardBackground)
candidateOverlay.setBackgroundColor(t.expandedBackground)
themeApplier.apply(this, t)
- topZone.applyTokens(t)
composition.setTextColor(ImeSurfacePolicy.selectedText(t))
topZone.candidateExpandButton.imageTintList = android.content.res.ColorStateList.valueOf(t.keyText)
topZone.candidateEmojiButton.imageTintList = android.content.res.ColorStateList.valueOf(t.keySecondaryText)
diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt
index 8832cfd8..b9553d9e 100644
--- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt
+++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt
@@ -365,15 +365,6 @@ internal class ImeThemeApplier(
tag == "backspace-clear-hint" ->
view.setTextColor(t.keySecondaryText)
- tag == "undo-clear-action" -> {
- view.setTextColor(t.primary)
- view.background = statefulRounded(
- Color.TRANSPARENT,
- ImeSurfacePolicy.subtleAccentSurface(t),
- toPx(ImeGeometryTokens.CONTROL_RADIUS_DP),
- )
- }
-
tag == "candidate-first" ->
view.setTextColor(t.keyText)
diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt
index f0a7e723..14afdbf4 100644
--- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt
+++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt
@@ -54,8 +54,7 @@ internal class ImeTopZone(
onHideKeyboard: () -> Unit,
onTools: () -> Unit,
onExpandCandidates: () -> Unit,
- private val onUndoClear: () -> Unit,
- private val onUndoClearExpired: () -> Unit,
+ private val onUndo: () -> Unit,
private val onAssociationDismiss: () -> Unit = {},
) : LinearLayout(context) {
val toolbarRow = LinearLayout(context)
@@ -73,9 +72,6 @@ internal class ImeTopZone(
val voiceInlineStatus = TextView(context)
val voiceInlineWaves = mutableListOf()
val associationBack = ImageView(context)
- val undoBanner = LinearLayout(context)
- private val undoBannerLabel = TextView(context)
- private val undoBannerAction = TextView(context)
private lateinit var toolbarIcons: List
private lateinit var compactToolbarIcons: List
private var compact = false
@@ -89,23 +85,14 @@ internal class ImeTopZone(
private lateinit var autofillScroll: HorizontalScrollView
private val autofillRow = LinearLayout(context)
- /**
- * What the toolbar row shows; icons, autofill chips, associations and the
- * undo banner are exclusive.
- */
- private enum class ToolbarMode { NORMAL, AUTOFILL, ASSOCIATION, UNDO }
+ /** What the toolbar row shows; icons, autofill chips and associations are exclusive. */
+ private enum class ToolbarMode { NORMAL, AUTOFILL, ASSOCIATION }
private fun idleToolbarMode(): ToolbarMode = when {
associationsShown -> ToolbarMode.ASSOCIATION
autofillShown -> ToolbarMode.AUTOFILL
else -> ToolbarMode.NORMAL
}
- private val hideUndoClearRunnable = Runnable {
- if (toolbarRow.findViewWithTag("undo-toolbar")?.isActivated == true) {
- toolbarRow.findViewWithTag("undo-toolbar")?.isActivated = false
- onUndoClearExpired()
- }
- }
init {
tag = "ime_toolbar"
@@ -125,7 +112,7 @@ internal class ImeTopZone(
val phraseIcon = toolbarIcon(R.drawable.ic_bubble, "常用语", "quick-phrase-toolbar", onQuickPhrases)
val emojiIcon = toolbarIcon(R.drawable.ic_emoji, "表情", "toolbar", onEmoji)
val textEditIcon = toolbarIcon(R.drawable.ic_text_cursor, "文本编辑", "toolbar", onTextEditor)
- val undoIcon = toolbarIcon(R.drawable.ic_undo, "撤销", "undo-toolbar") { onUndoClear() }
+ val undoIcon = toolbarIcon(R.drawable.ic_undo, "撤销", "undo-toolbar") { onUndo() }
val toolsIcon = toolbarIcon(R.drawable.ic_grid, "更多", "toolbar", onTools)
// Docked keeps the full toolbar. Floating (game) mode drops text editing
// and undo and puts quick phrases one tap away.
@@ -205,43 +192,6 @@ internal class ImeTopZone(
keyboardHide = toolbarIcon(R.drawable.ic_chevron_down, "收起键盘", "keyboard-hide", onHideKeyboard)
toolbarRow.addView(keyboardHide, LinearLayout.LayoutParams(0, toPx(48), 1f))
- // After a clear-all: "已清空 [撤销]" for the undo window.
- undoBanner.apply {
- tag = "undo-banner"
- orientation = LinearLayout.HORIZONTAL
- gravity = Gravity.CENTER_VERTICAL
- visibility = View.GONE
- setPadding(toPx(16), 0, toPx(8), 0)
- }
- undoBannerLabel.apply {
- text = "已清空"
- textSize = ImeTypographyTokens.BODY_SP
- includeFontPadding = false
- gravity = Gravity.CENTER_VERTICAL
- importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
- }
- undoBannerAction.apply {
- tag = "undo-clear-action"
- text = "撤销"
- textSize = ImeTypographyTokens.BODY_SP
- typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
- gravity = Gravity.CENTER
- includeFontPadding = false
- minWidth = toPx(72)
- setPadding(toPx(16), 0, toPx(16), 0)
- isClickable = true
- isFocusable = true
- contentDescription = "撤销清空"
- setOnClickListener {
- removeCallbacks(hideUndoClearRunnable)
- hideUndoClear()
- onFeedback()
- onUndoClear()
- }
- }
- undoBanner.addView(undoBannerLabel, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f))
- undoBanner.addView(undoBannerAction, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, toPx(40)))
- toolbarRow.addView(undoBanner, LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f))
addView(
toolbarRow,
LinearLayout.LayoutParams(
@@ -448,14 +398,14 @@ internal class ImeTopZone(
fun showAssociations(show: Boolean) {
associationsShown = show
- if (toolbarMode != ToolbarMode.UNDO) toolbarMode = idleToolbarMode()
+ toolbarMode = idleToolbarMode()
refreshToolbar()
}
/**
* Shows the autofill [chips] (already inflated by the system) in the toolbar
- * row, or the normal toolbar again when the list is empty. Associations and
- * the undo banner keep priority for as long as they are showing.
+ * row, or the normal toolbar again when the list is empty. Associations keep
+ * priority for as long as they are showing.
*/
fun setAutofillChips(chips: List, chipWidthPx: Int = 0, chipHeightPx: Int = 0) {
autofillRow.removeAllViews()
@@ -474,7 +424,7 @@ internal class ImeTopZone(
}
autofillShown = chips.isNotEmpty()
autofillScroll.scrollTo(0, 0)
- if (toolbarMode != ToolbarMode.UNDO) toolbarMode = idleToolbarMode()
+ toolbarMode = idleToolbarMode()
refreshToolbar()
}
@@ -482,27 +432,8 @@ internal class ImeTopZone(
private fun dismissAutofillChips() {
autofillRow.removeAllViews()
autofillShown = false
- if (toolbarMode != ToolbarMode.UNDO) toolbarMode = idleToolbarMode()
- refreshToolbar()
- }
-
- fun showUndoClear() {
- removeCallbacks(hideUndoClearRunnable)
- toolbarRow.findViewWithTag("undo-toolbar")?.isActivated = true
- toolbarMode = ToolbarMode.UNDO
+ toolbarMode = idleToolbarMode()
refreshToolbar()
- postDelayed(hideUndoClearRunnable, CLEAR_UNDO_VISIBLE_MS)
- }
-
- fun hideUndoClear(discardSnapshot: Boolean = false) {
- removeCallbacks(hideUndoClearRunnable)
- val wasVisible = toolbarRow.findViewWithTag("undo-toolbar")?.isActivated == true
- toolbarRow.findViewWithTag("undo-toolbar")?.isActivated = false
- if (toolbarMode == ToolbarMode.UNDO) {
- toolbarMode = idleToolbarMode()
- refreshToolbar()
- }
- if (discardSnapshot && wasVisible) onUndoClearExpired()
}
private var longPressDrag: FloatingDragController? = null
@@ -594,8 +525,7 @@ internal class ImeTopZone(
associationBack.visibility = if (association || autofill) View.VISIBLE else View.GONE
associationScroll.visibility = if (association) View.VISIBLE else View.GONE
autofillScroll.visibility = if (autofill) View.VISIBLE else View.GONE
- undoBanner.visibility = if (toolbarMode == ToolbarMode.UNDO) View.VISIBLE else View.GONE
- keyboardHide.visibility = if (toolbarMode == ToolbarMode.UNDO) View.GONE else View.VISIBLE
+ keyboardHide.visibility = View.VISIBLE
(keyboardHide.layoutParams as? LinearLayout.LayoutParams)?.let { params ->
if (association || autofill) {
params.width = toPx(ImeGeometryTokens.TOUCH_TARGET_DP)
@@ -608,16 +538,6 @@ internal class ImeTopZone(
}
}
- /** Colour the undo banner from the active tokens (called with every theme pass). */
- fun applyTokens(t: ImeTheme.Tokens) {
- undoBannerLabel.setTextColor(t.keyText)
- undoBannerAction.setTextColor(ImeSurfacePolicy.selectedText(t))
- undoBannerAction.background = ImeDrawableFactory.rounded(
- ImeSurfacePolicy.subtleAccentSurface(t),
- toPx(ImeGeometryTokens.PILL_RADIUS_DP),
- )
- }
-
fun setContentInset(contentInsetPx: Int) {
toolbarRow.setPadding(contentInsetPx + toPx(6), 0, contentInsetPx + toPx(6), 0)
composition.setPadding(contentInsetPx + toPx(14), toPx(3), contentInsetPx + toPx(14), 0)
@@ -629,7 +549,6 @@ internal class ImeTopZone(
) {
val composing = state == ImeTopZoneState.COMPOSING ||
state == ImeTopZoneState.CANDIDATE_EXPANDED
- if (composing && toolbarMode == ToolbarMode.UNDO) hideUndoClear()
toolbarRow.visibility = if (state == ImeTopZoneState.IDLE) View.VISIBLE else View.GONE
composeZone.visibility = if (composing) View.VISIBLE else View.GONE
voiceInlineZone.visibility = if (state == ImeTopZoneState.VOICE_INLINE) View.VISIBLE else View.GONE
@@ -666,8 +585,4 @@ internal class ImeTopZone(
toPx(ImeGeometryTokens.TOUCH_TARGET_DP),
toPx(ImeGeometryTokens.TOUCH_TARGET_DP),
)
-
- private companion object {
- const val CLEAR_UNDO_VISIBLE_MS = 5_000L
- }
}
diff --git a/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt
new file mode 100644
index 00000000..739f2e46
--- /dev/null
+++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt
@@ -0,0 +1,114 @@
+package llc.slacker.openime.keyboard
+
+import android.content.Context
+import android.media.AudioAttributes
+import android.os.Build
+import android.os.VibrationAttributes
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
+import android.view.HapticFeedbackConstants
+import android.view.View
+
+/**
+ * Key-press haptics: one short, full-strength click.
+ *
+ * `performHapticFeedback(KEYBOARD_TAP)` is the platform default, but since
+ * Android 14 QPR3 the system plays it as PRIMITIVE_CLICK at the device's fixed
+ * keyboard amplitude, which many phones tune low, and Gboard lost its own
+ * strength slider to that system setting. Commercial IMEs (Sogou, Huawei 小艺,
+ * HeliBoard's custom mode) drive the vibrator themselves instead. This does the
+ * same with the crispest effect the actuator offers:
+ *
+ * - API 30+ with primitive support: PRIMITIVE_CLICK, scaled by [strengthPercent];
+ * - otherwise with amplitude control: a 20 ms one-shot at that share of full amplitude;
+ * - otherwise: API 29+ uses the actuator-tuned EFFECT_CLICK at full strength and a
+ * shorter pulse below it (motors without amplitude control only vary by time).
+ *
+ * The 震动强度 slider sets [strengthPercent] (10–100, like Sogou's and Huawei
+ * 小艺's strength settings); 100 is the full click.
+ *
+ * The vibration is tagged as touch feedback, so the system "touch feedback"
+ * intensity still applies; the in-app 触感震动 switch turns it off entirely.
+ * A device without a vibrator falls back to the view's haptic feedback.
+ */
+internal class KeyHaptics(context: Context) {
+ private val vibrator: Vibrator? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ context.getSystemService(VibratorManager::class.java)?.defaultVibrator
+ } else {
+ @Suppress("DEPRECATION")
+ context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
+ }
+
+ /** 10–100; the share of the full click. Changing it rebuilds the effect. */
+ var strengthPercent: Int = MAX_STRENGTH
+ set(value) {
+ val bounded = value.coerceIn(MIN_STRENGTH, MAX_STRENGTH)
+ if (field == bounded) return
+ field = bounded
+ cachedClick = null
+ }
+
+ private var cachedClick: VibrationEffect? = null
+ private val click: VibrationEffect?
+ get() = cachedClick ?: buildClick().also { cachedClick = it }
+
+ fun click(view: View) {
+ val target = vibrator
+ val effect = click
+ if (target == null || effect == null || !target.hasVibrator()) {
+ view.performHapticFeedback(FALLBACK_CONSTANT)
+ return
+ }
+ runCatching {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ target.vibrate(effect, VibrationAttributes.createForUsage(VibrationAttributes.USAGE_TOUCH))
+ } else {
+ @Suppress("DEPRECATION")
+ target.vibrate(effect, TOUCH_AUDIO_ATTRIBUTES)
+ }
+ }.onFailure { view.performHapticFeedback(FALLBACK_CONSTANT) }
+ }
+
+ private fun buildClick(): VibrationEffect? {
+ val target = vibrator ?: return null
+ val share = strengthPercent / 100f
+ if (
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
+ target.areAllPrimitivesSupported(VibrationEffect.Composition.PRIMITIVE_CLICK)
+ ) {
+ return VibrationEffect.startComposition()
+ .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, share)
+ .compose()
+ }
+ if (target.hasAmplitudeControl()) {
+ return VibrationEffect.createOneShot(LEGACY_CLICK_MS, (255 * share).toInt().coerceIn(1, 255))
+ }
+ if (strengthPercent == MAX_STRENGTH && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ return VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)
+ }
+ val durationMs = (MIN_PULSE_MS + (LEGACY_CLICK_MS - MIN_PULSE_MS) * share).toLong()
+ return VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE)
+ }
+
+ companion object {
+ const val MIN_STRENGTH = 10
+ const val MAX_STRENGTH = 100
+ private const val LEGACY_CLICK_MS = 20L
+ private const val MIN_PULSE_MS = 6L
+
+ /** KEYBOARD_TAP needs API 27; API 26 falls back to the virtual-key click. */
+ private val FALLBACK_CONSTANT =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ HapticFeedbackConstants.KEYBOARD_TAP
+ } else {
+ HapticFeedbackConstants.VIRTUAL_KEY
+ }
+
+ private val TOUCH_AUDIO_ATTRIBUTES: AudioAttributes = AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+ .build()
+ }
+}
diff --git a/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt
index 398b36f7..5f91e337 100644
--- a/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt
+++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt
@@ -93,19 +93,9 @@ internal class KeyPopupController(
return
}
val t = tokens()
- val clear = hint == GestureHint.CLEAR_PREVIEW || hint == GestureHint.CLEAR_ARMED
- val armed = hint == GestureHint.CLEAR_ARMED || hint == GestureHint.UNDO_ARMED
- val label = when (hint) {
- GestureHint.CLEAR_PREVIEW -> "上滑清空"
- GestureHint.CLEAR_ARMED -> "松手清空"
- GestureHint.UNDO_PREVIEW -> "下滑撤回"
- else -> "松手撤回"
- }
- val fill = when {
- !armed -> ImeDrawableFactory.withAlpha(t.keyText, 0xE0)
- clear -> t.destructive
- else -> t.primary
- }
+ val armed = hint == GestureHint.CLEAR_ARMED
+ val label = if (armed) "松手清空" else "上滑清空"
+ val fill = if (armed) t.destructive else ImeDrawableFactory.withAlpha(t.keyText, 0xE0)
val textColor = if (armed) contrastText(fill) else t.keyBackground
val height = dp(GESTURE_HINT_HEIGHT_DP)
diff --git a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt
index 38b2d376..f8f24a3d 100644
--- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt
+++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt
@@ -78,6 +78,12 @@ internal class Pinyin26KeyboardRenderer(
createKey("123", true, null, ImeTypographyTokens.BODY_SP, 0, onDigits),
flexKeyParams(weights.leftOuter),
)
+ // ,。 sits left of space and 中/英 right of it, next to the enter key.
+ bottom.addView(punctuationKey(english), flexKeyParams(weights.leftInner))
+ bottom.addView(
+ createSpaceVoiceKey("空格", onSpace),
+ flexKeyParams(weights.space),
+ )
bottom.addView(
createKey(
"中/英",
@@ -86,13 +92,8 @@ internal class Pinyin26KeyboardRenderer(
ImeTypographyTokens.BODY_SP,
0,
) { onModeSwitch() }.apply { tag = "key:mode" },
- flexKeyParams(weights.leftInner),
- )
- bottom.addView(
- createSpaceVoiceKey("空格", onSpace),
- flexKeyParams(weights.space),
+ flexKeyParams(weights.rightInner),
)
- bottom.addView(punctuationKey(english), flexKeyParams(weights.rightInner))
bottom.addView(
createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, 0, onEnter).apply {
tag = "key-enter"
diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt
index e4b8ced1..e65a783b 100644
--- a/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt
+++ b/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt
@@ -22,7 +22,7 @@ internal object ProductionKeyPolicy {
/**
* Visual reference geometry for the shared Chinese/English 26-key bottom row:
- * 123 | 中/英 | 空格 | ,。 | 确定. The row mirrors around the space key so the
+ * 123 | ,。 | 空格 | 中/英 | 确定. The row mirrors around the space key so the
* space bar sits on the keyboard's centre line.
*/
fun twentySixKeyBottomRowWeights(): BottomRowWeights {
diff --git a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt
index bba91702..b3d2041b 100644
--- a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt
+++ b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt
@@ -61,6 +61,7 @@ internal class ImePanelRenderer(
private val onModeSelected: (KeyboardMode) -> Unit,
private val onShowPanel: (Panel) -> Unit,
private val onEnableFloatingKeyboard: () -> Unit,
+ private val onOpenDataManagement: () -> Unit,
private val onSymbolSelected: (String) -> Unit,
private val onCharacter: (String) -> Unit,
private val onSpace: () -> Unit,
@@ -137,11 +138,12 @@ internal class ImePanelRenderer(
ToolEntry("剪贴板", Panel.CLIPBOARD, R.drawable.ic_pref_clipboard),
ToolEntry("表情", Panel.EMOJI, R.drawable.ic_pref_emoji),
ToolEntry("符号", Panel.SYMBOLS, R.drawable.ic_pref_symbols),
- ToolEntry("语音输入", Panel.VOICE, R.drawable.ic_pref_mic),
- ToolEntry("切换键盘", Panel.KEYBOARD_SELECT, R.drawable.ic_pref_globe),
ToolEntry("文本编辑", Panel.TEXT_EDITOR, R.drawable.ic_pref_cursor),
ToolEntry("浮动键盘", iconRes = R.drawable.ic_pref_floating, action = onEnableFloatingKeyboard),
ToolEntry("设置", Panel.SETTINGS, R.drawable.ic_pref_settings),
+ // Voice is a long-press on space and the keyboard switcher sits on the
+ // toolbar, so neither repeats here.
+ ToolEntry("数据管理", iconRes = R.drawable.ic_pref_data, action = onOpenDataManagement),
).filter { it.enabled }
cards.chunked(4).forEach { chunk ->
diff --git a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt
index 249c1656..1ea069ef 100644
--- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt
+++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt
@@ -50,6 +50,7 @@ internal class SettingsPanelController(
private val currentAppearance: () -> ImeAppearance,
private val currentSound: () -> Boolean,
private val currentHaptic: () -> Boolean,
+ private val currentHapticStrengthPercent: () -> Int,
private val currentPopup: () -> Boolean,
private val currentSwipeUpDigits: () -> Boolean,
/** State of the toggles added after the first four, looked up by their label. */
@@ -62,9 +63,11 @@ internal class SettingsPanelController(
private val onAppearanceSelected: (ImeAppearance) -> Unit,
private val onToggleChanged: (String, Boolean) -> Unit,
private val onKeyboardHeightChanged: (Int) -> Unit,
+ private val onHapticStrengthChanged: (Int) -> Unit,
private val onFloatingStyleChanged: (Int, Int) -> Unit,
private val onShowFuzzySettings: () -> Unit,
- private val onOpenAboutData: () -> Unit,
+ private val onOpenAbout: () -> Unit,
+ private val onOpenDataManagement: () -> Unit,
private val onFeedback: () -> Unit,
private val applyTheme: () -> Unit,
private val onHierarchyRebuilt: () -> Unit,
@@ -172,6 +175,7 @@ internal class SettingsPanelController(
content.addCard(
toggleRow("按键音效", "机械轴敲击反馈", R.drawable.ic_pref_sound),
toggleRow("触感震动", "清脆短促,按下即停", R.drawable.ic_pref_haptic),
+ sliderRow("震动强度", R.drawable.ic_pref_haptic, 10, 100, currentHapticStrengthPercent(), onChange = onHapticStrengthChanged),
toggleRow("按键气泡", "按下时显示字母预览", R.drawable.ic_pref_bubble),
toggleRow("数字和符号提示", "字母键右上角显示数字和符号", R.drawable.ic_pref_hints),
toggleRow("上滑输入数字", "按键上滑输入右上角的数字或符号", R.drawable.ic_pref_swipe_up),
@@ -186,9 +190,10 @@ internal class SettingsPanelController(
toggleRow("语音去语气词", "去掉“嗯”“呃”等口头停顿", R.drawable.ic_pref_waveform),
toggleRow("标点用空格代替", "语音里的逗号、句号等写成空格", R.drawable.ic_pref_space),
)
- content.addSection("数据")
+ content.addSection("关于与数据")
content.addCard(
- navigationRow("关于与数据", "版本、隐私、导出与导入", R.drawable.ic_pref_info, onOpenAboutData),
+ navigationRow("关于", "版本、隐私与诊断", R.drawable.ic_pref_info, onOpenAbout),
+ navigationRow("数据管理", "导出与导入、语音词表", R.drawable.ic_pref_data, onOpenDataManagement),
)
}
@@ -205,6 +210,7 @@ internal class SettingsPanelController(
content.addCard(
segmentedRow("外观", iconRes = 0),
sliderRow("键盘高度", 0, 80, 120, currentKeyboardHeightPercent(), onChange = onKeyboardHeightChanged),
+ sliderRow("震动强度", 0, 10, 100, currentHapticStrengthPercent(), onChange = onHapticStrengthChanged),
topMarginDp = ImeSpacingTokens.MD_DP,
)
content.addSection("浮动键盘")
@@ -227,9 +233,10 @@ internal class SettingsPanelController(
toggleRow("语音去语气词", null, 0),
toggleRow("标点用空格代替", null, 0),
)
+ content.addSection("关于与数据")
content.addCard(
- navigationRow("关于与数据", null, 0, onOpenAboutData),
- topMarginDp = ImeSpacingTokens.MD_DP,
+ navigationRow("关于", null, 0, onOpenAbout),
+ navigationRow("数据管理", null, 0, onOpenDataManagement),
)
}
diff --git a/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt b/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt
index e5292109..0646f9b2 100644
--- a/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt
+++ b/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt
@@ -93,6 +93,48 @@ object SetupUi {
dp(context, ImeGeometryTokens.CARD_RADIUS_DP).toFloat(),
)
+ /**
+ * A titled explanation card for the About and Data pages: neutral icon tile,
+ * title, body. Callers may append buttons below the text.
+ */
+ fun infoCard(context: Context, title: String, body: String, iconRes: Int): LinearLayout =
+ LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ val pad = dp(context, ImeSpacingTokens.LG_DP)
+ setPadding(pad, pad, pad, pad)
+ background = cardBackground(context)
+ addView(LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ addView(android.widget.ImageView(context).apply {
+ setImageResource(iconRes)
+ imageTintList = ColorStateList.valueOf(context.getColor(R.color.setup_icon))
+ scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
+ setPadding(dp(context, 7), dp(context, 7), dp(context, 7), dp(context, 7))
+ background = rounded(context.getColor(R.color.setup_icon_tile), dp(context, 9).toFloat())
+ importantForAccessibility = android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO
+ }, LinearLayout.LayoutParams(dp(context, 32), dp(context, 32)).apply { marginEnd = dp(context, 12) })
+ addView(LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ addView(TextView(context).apply {
+ text = title
+ textSize = ImeTypographyTokens.ROW_SP
+ setTextColor(context.getColor(R.color.setup_title))
+ typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
+ if (Build.VERSION.SDK_INT >= 28) isAccessibilityHeading = true
+ })
+ addView(TextView(context).apply {
+ text = body
+ textSize = ImeTypographyTokens.DETAIL_SP
+ setTextColor(context.getColor(R.color.setup_body))
+ setLineSpacing(0f, 1.3f)
+ }, LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ ).apply { topMargin = dp(context, ImeSpacingTokens.XS_DP) })
+ }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
+ })
+ }
+
/** Press feedback for a row inside a card; the card itself paints the surface. */
fun rowBackground(context: Context): StateListDrawable =
ImeDrawableFactory.statefulRounded(
diff --git a/app/src/main/res/drawable/ic_pref_data.xml b/app/src/main/res/drawable/ic_pref_data.xml
new file mode 100644
index 00000000..ff396b56
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pref_data.xml
@@ -0,0 +1 @@
+
diff --git a/app/src/main/res/drawable/ic_pref_shield.xml b/app/src/main/res/drawable/ic_pref_shield.xml
new file mode 100644
index 00000000..6ba4f16e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pref_shield.xml
@@ -0,0 +1 @@
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 86649d0e..e3bc1510 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -51,5 +51,6 @@
常用语
自定义符号
语音词表
- 关于与数据
+ 关于
+ 数据管理
diff --git a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt
index 336f4d3a..aa86c32d 100644
--- a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt
+++ b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt
@@ -321,54 +321,6 @@ class InputConnectionGatewayTest {
assertTrue(fake.events.none { it.startsWith("delete") })
}
- @Test
- fun clearUndoExpiresAfterFiveSeconds() {
- var now = 1_000L
- val fake = FakeInputConnection(
- selectedText = "可恢复文本",
- contextMenuResult = true,
- extractedText = extracted("", 0, 0, 0),
- )
- val gateway = InputConnectionGateway(null, { fake }, nowMs = { now })
-
- assertTrue(gateway.clearAllText())
- assertTrue(gateway.hasClearUndo())
-
- now += 5_001L
- assertFalse(gateway.hasClearUndo())
- assertFalse(gateway.restoreLastClear())
- }
-
- @Test
- fun clearUndoIsBoundToTheOriginalInputConnection() {
- val first = FakeInputConnection(
- selectedText = "第一个输入框",
- contextMenuResult = true,
- extractedText = extracted("", 0, 0, 0),
- )
- val second = FakeInputConnection(extractedText = extracted("", 0, 0, 0))
- var current: InputConnection = first
- val gateway = InputConnectionGateway(null, { current }, nowMs = { 1_000L })
-
- assertTrue(gateway.clearAllText())
- assertTrue(gateway.hasClearUndo())
-
- current = second
- assertFalse(gateway.hasClearUndo())
- assertFalse(gateway.restoreLastClear())
- assertTrue(second.events.none { it.startsWith("commit:") })
- }
-
- @Test
- fun passwordClearNeverCreatesUndoSnapshot() {
- val fake = FakeInputConnection(selectedText = "secret", contextMenuResult = true)
- val gateway = InputConnectionGateway(null, { fake }, isPassword = { true })
-
- assertFalse(gateway.clearAllText())
- assertFalse(gateway.hasClearUndo())
- assertFalse(gateway.restoreLastClear())
- }
-
@Test
fun failedClearAllRestoresSelectionWithoutPreDeletingContent() {
val fake = FakeInputConnection(
@@ -621,26 +573,6 @@ class InputConnectionGatewayTest {
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
@@ -650,10 +582,6 @@ class InputConnectionGatewayTest {
assertTrue(gateway.clearAllText())
assertEquals("", editor.text.toString())
-
- assertTrue(gateway.restoreLastClear())
- assertEquals("0123456789", editor.text.toString())
- assertEquals(5, editor.selStart)
}
@Test
@@ -663,16 +591,14 @@ class InputConnectionGatewayTest {
assertFalse(gateway.clearAllText())
assertEquals("abcdef", editor.text.toString())
- assertFalse(gateway.hasClearUndo())
}
@Test
- fun clearAllOnAnAlreadyEmptySurroundingTextEditorSucceedsWithoutUndo() {
+ fun clearAllOnAnAlreadyEmptySurroundingTextEditorSucceeds() {
val editor = SurroundingOnlyEditor("", selStart = 0, selEnd = 0)
val gateway = InputConnectionGateway(null, { editor })
assertTrue(gateway.clearAllText())
- assertFalse(gateway.hasClearUndo())
}
// --- terminals, games and remote desktops: TYPE_NULL, key events only ---
diff --git a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt
index 2e70a203..55333cf9 100644
--- a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt
+++ b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt
@@ -9,7 +9,7 @@ class ProductionKeyPolicyTest {
@Test
fun twentySixKeyBottomRowFollowsTheReferenceCanvas() {
val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights()
- // 123 | 中/英 | 空格 | ,。 | 确定, in the design's 390 units.
+ // 123 | ,。 | 空格 | 中/英 | 确定, in the design's 390 units.
assertEquals(70f, weights.leftOuter, 0.0001f)
assertEquals(48f, weights.leftInner, 0.0001f)
assertEquals(154f, weights.space, 0.0001f)
diff --git a/docs/APP_UI_SPEC.md b/docs/APP_UI_SPEC.md
index 785325e6..20bcfbc1 100644
--- a/docs/APP_UI_SPEC.md
+++ b/docs/APP_UI_SPEC.md
@@ -71,7 +71,8 @@ Apple 的 pt 不是 Android 的 dp/sp。下面是本项目在 Android 上的实
## 键盘内面板
- 设置面板与键盘同高,首屏依次是四个快捷开关(按键音效、触感震动、按键气泡、数字提示,每个 68dp 高)、外观(分段选项与标签同行,宽 216dp)、键盘高度;其余设置在下方滚动。行高 52dp,不带图标和说明。
-- 工具面板:4 列 × 2 行,每项为 56dp 圆角 16dp 的图标块加 12sp 名称,图标为中性色。
+- 工具面板:剪贴板、表情、符号、文本编辑、浮动键盘、设置、数据管理,4 列排布;每项为 56dp 圆角 16dp 的图标块加 12sp 名称,图标为中性色。不放语音输入(按住空格即可)和切换键盘(工具栏已有)。
+- 偏好设置与键盘内设置的最后一组标题为“关于与数据”,下面两行:“关于”(版本、隐私、诊断)和“数据管理”(导出与导入、语音词表、卸载前提示)。
- 标题行与面板同底色,返回按钮无底色,按下才显示反馈。
## 宽度与字体变化
@@ -90,9 +91,11 @@ Apple 的 pt 不是 Android 的 dp/sp。下面是本项目在 Android 上的实
## 按键触感
-- 按键按下用 `HapticFeedbackConstants.KEYBOARD_TAP`(Android 8.0 上回退 `VIRTUAL_KEY`),是系统键盘同款 10–20ms 短击。
-- 删除键手势到位、按住空格进入语音用 `CONTEXT_CLICK`,单下清脆点击。
-- 按键反馈不使用 `LONG_PRESS`、`CLOCK_TICK` 或自定义时长的振动,避免余震拖沓。依据 Android [Haptics design principles](https://developer.android.com/develop/ui/views/haptics/haptics-principles)。
+- Android 14 QPR3 起,`performHapticFeedback(KEYBOARD_TAP)` 由系统按设备配置的固定键盘振幅播放,多数机型偏弱;Gboard 也因此失去了自己的强度滑块。搜狗、小艺、HeliBoard 等都自己驱动振动马达。
+- 本项目由 `KeyHaptics` 直接调用 `Vibrator`:Android 11 起若支持则播放 `PRIMITIVE_CLICK`(强度 1.0),Android 10 用 `EFFECT_CLICK`,Android 8–9 用 20ms 满幅脉冲;用途标记为触摸反馈,仍跟随系统“触摸反馈”强度。没有振动马达时回退 `KEYBOARD_TAP`。
+- “震动强度”滑块 10%–100%(默认 100%)缩放这一下点击:支持原语时按比例缩放 `PRIMITIVE_CLICK`,有振幅控制时缩放振幅,否则缩短脉冲时长;拖动滑杆时最多每 80ms 试震一次。偏好设置放在“触感震动”下方,键盘内设置放在首屏“键盘高度”下方。
+- 按键按下、删除键手势到位、按住空格进入语音都用这一下点击;不使用 `LONG_PRESS`、`CLOCK_TICK` 或长时长振动,避免余震拖沓。依据 Android [Haptics design principles](https://developer.android.com/develop/ui/views/haptics/haptics-principles)。
+- 按键音用带音量参数的 `AudioManager.playSoundEffect(FX_KEYPRESS_STANDARD, -1)`:单参数版本在系统“触摸提示音”关闭时不出声。
## 验收与可重复工件
diff --git a/docs/NINE_KEY_REFERENCE.md b/docs/NINE_KEY_REFERENCE.md
index 71282a2b..73864d15 100644
--- a/docs/NINE_KEY_REFERENCE.md
+++ b/docs/NINE_KEY_REFERENCE.md
@@ -36,12 +36,12 @@
## 删除键手势
-- 上滑 ≥ 32dp 清空,下滑 ≥ 32dp 撤回(仅清空后 5 秒内);松手瞬间的坐标也算最后一次移动。
-- 反馈只有一个气泡组件:未到位时深色「上滑清空 / 下滑撤回」,到位后变色「松手清空(红)/ 松手撤回(强调色)」,
- 放在键的侧边而不是上方(拇指会挡住上方)。清空后顶栏显示「已清空 · 撤销」。
+- 上滑 ≥ 32dp 清空;松手瞬间的坐标也算最后一次移动。清空是最终操作,不提供下滑撤回(产品决定)。
+- 反馈只有一个气泡组件:未到位时深色「上滑清空」,到位后变红「松手清空」,
+ 放在键的侧边而不是上方(拇指会挡住上方)。
- 「清空全部」不能只依赖编辑器的全选动作:自绘 / Compose / Web 输入框通常既没有全选也没有完整的
ExtractedText。网关因此有第三条路径:只用光标前后文本,抓取全部内容、循环删除直到编辑器报告为空,
- 并保留撤回快照。答案长度等于请求长度时可能只是窗口,一律拒绝删除。
+ 中途失败则把已删内容放回。答案长度等于请求长度时可能只是窗口,一律拒绝删除。
- 调试用 `CustomEditorTestActivity` 复现这类编辑器(修复前手势触发但文字纹丝不动,且无提示)。
## 验证
diff --git a/docs/TEST_SOP.md b/docs/TEST_SOP.md
index 64c38949..015bd1eb 100644
--- a/docs/TEST_SOP.md
+++ b/docs/TEST_SOP.md
@@ -150,9 +150,8 @@ OpenIME
长按松手立即停止,空状态继续删除不能崩溃。
上滑清空必须满足:上滑 ≥ 32dp 才进入(未到位是深色「上滑清空」提示,进入后变红色「松手清空」);
-进入时轻反馈;滑回/侧滑取消;松手才执行一次;清空后正文、拼音、候选一致,顶栏显示「已清空 · 撤销」。
-下滑 ≥ 32dp 撤回,仅在清空后 5 秒内可用(提示为「下滑撤回」→ 强调色「松手撤回」)。
-两者必须在没有「全选」也没有完整 ExtractedText 的输入框(自绘 / Compose / Web,可用调试
+进入时轻反馈;滑回/侧滑取消;松手才执行一次;清空后正文、拼音、候选一致,不出现撤回提示或「已清空 · 撤销」。
+清空必须在没有「全选」也没有完整 ExtractedText 的输入框(自绘 / Compose / Web,可用调试
`CustomEditorTestActivity` 复现)里同样生效。
## 9. 空格、语音、气泡和震动
diff --git a/scripts/design_reference_e2e.py b/scripts/design_reference_e2e.py
index 79ef5c7b..a726e2c0 100644
--- a/scripts/design_reference_e2e.py
+++ b/scripts/design_reference_e2e.py
@@ -228,13 +228,11 @@ def pref_items(items):
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:浮动键盘'))
+ 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:键盘高度')),
]:
@@ -251,8 +249,11 @@ def pref_items(items):
capture_app(stem + '-setup', ('shortcut_preferences',))
ui_tap(pkg + ':id/shortcut_preferences')
capture_app(stem + '-preferences-full', ('偏好设置', '跟随系统'))
- ui_tap('关于与数据')
- capture_app(stem + '-about', ('隐私','用户数据','导出','导入'))
+ ui_tap('关于')
+ capture_app(stem + '-about', ('隐私','诊断'))
+ adb('shell','input','keyevent','4'); time.sleep(.5)
+ ui_tap('数据管理')
+ capture_app(stem + '-data', ('用户数据','导出','导入','语音词表'))
launch()
panel('符号', stem + '-symbols-return')
tap('自定义'); tap('管理自定义符号')