Files
openless/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt
TRIPandHKLHaoBin 6f9af89583 release: OpenLess 2.0.0-Beta.4+build.20260930 (#1126)
Integrate ASR/Omni fixes, API Route, Android IME and confirmation-based vocabulary learning. Complete settings transitions and macOS single-item credential storage. Synchronize all application versions to 2.0.0-Beta.4+build.20260930.

Integrated PRs: #1116, #1120, #1121, #1123, #1124, #1067 and #1128. Linux #1117 remains independent and is excluded from automatic release.

Validated: full cross-platform CI, 18 UI motion cases, 112 frontend test files, 1245 Core tests (1 ignored) and 572 macOS native tests.

Co-authored-by: HKLHaoBin <we3q@qq.com>
2026-09-30 20:32:53 +08:00

930 lines
48 KiB
Kotlin

package com.openless.app
import android.app.Activity
import android.content.Context
import android.content.res.Configuration
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.SeekBar
import android.widget.Switch
import android.widget.TextView
import android.widget.Toast
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
/**
* Full-screen native settings window opened by long-pressing the OpenLess
* logo in the keyboard panels. Keyboard-only preferences that don't need the
* full WebView app live here. Framework only for now (vibration intensity
* and duration) — more rows get appended to `content` in buildContent() as
* they're added.
*/
class OpenLessKeyboardSettingsActivity : Activity() {
private val prefs by lazy { getSharedPreferences("openless_ime_ui", Context.MODE_PRIVATE) }
// Export/import (see OpenLessSettingsExport): plain Activity, so this
// uses the classic startActivityForResult()/onActivityResult() pair
// rather than androidx.activity's ActivityResultContracts, which needs
// ComponentActivity. pendingExportJson bridges the gap between building
// the export JSON (at the moment the user picks categories) and
// actually having somewhere to write it (only known once
// ACTION_CREATE_DOCUMENT's picker returns a Uri, an async round trip).
private var pendingExportJson: String? = null
private val requestCodeExportSave = 4201
private val requestCodeImportOpen = 4202
private val englishUi by lazy {
val locale = prefs.getString("locale", null) ?: resources.configuration.locales[0].toLanguageTag()
!locale.startsWith("zh", ignoreCase = true)
}
private fun ui(zh: String, en: String) = if (englishUi) en else zh
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
/** Same "which theme is the keyboard actually showing" logic as OpenLessImeService.isDarkTheme, so this screen matches whatever the user is looking at when they long-press the Logo to get here. */
private val isDarkTheme: Boolean
get() = when (prefs.getString("theme_mode", null)) {
"light" -> false
"dark" -> true
else -> (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) != Configuration.UI_MODE_NIGHT_NO
}
private fun tone(dark: Int, light: Int): Int = if (isDarkTheme) dark else light
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Explicit rather than relying on targetSdk 36's implicit
// edge-to-edge enforcement — guarantees the IME-inset listener in
// buildContent() actually fires so the "云笔记提交" URL/Token fields
// stay reachable once the keyboard covers part of the screen.
WindowCompat.setDecorFitsSystemWindows(window, false)
setContentView(buildContent())
// Some OEM ROMs only wire up the WindowInsets/IME dispatch chain
// correctly once a WindowInsetsControllerCompat has actually been
// instantiated for this window — never used for show()/hide() here,
// just created as the standard companion call to
// setDecorFitsSystemWindows(false) above.
WindowCompat.getInsetsController(window, window.decorView)
}
private fun buildContent(): View {
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(tone(Color.rgb(30, 30, 30), Color.rgb(245, 245, 247)))
}
val header = LinearLayout(this).apply {
gravity = Gravity.CENTER_VERTICAL
setPadding(0, dp(8), dp(16), dp(8))
}
header.addView(
TextView(this).apply {
text = "←"
textSize = 22f
setTextColor(tone(Color.WHITE, Color.rgb(30, 30, 34)))
gravity = Gravity.CENTER
setPadding(dp(16), dp(8), dp(16), dp(8))
isClickable = true
setOnClickListener { finish() }
},
)
header.addView(
TextView(this).apply {
text = ui("键盘设置", "Keyboard settings")
textSize = 18f
setTypeface(typeface, android.graphics.Typeface.BOLD)
setTextColor(tone(Color.WHITE, Color.rgb(30, 30, 34)))
},
)
root.addView(header, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
val scroll = ScrollView(this)
val content = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(20), dp(12), dp(20), dp(20))
}
scroll.addView(content, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
root.addView(scroll, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
// Live 1:1 footprint + its controlling sliders stay docked under the
// scrollable settings so dragging always updates a visible silhouette
// without scrolling the form away.
var heightDp = prefs.getInt(
OpenLessImeService.PREF_KEYBOARD_HEIGHT_DP,
OpenLessImeService.DEFAULT_KEYBOARD_HEIGHT_DP,
).coerceIn(OpenLessImeService.MIN_KEYBOARD_HEIGHT_DP, OpenLessImeService.MAX_KEYBOARD_HEIGHT_DP)
var raiseDp = prefs.getInt(
OpenLessImeService.PREF_KEYBOARD_RAISE_DP,
OpenLessImeService.DEFAULT_KEYBOARD_RAISE_DP,
).coerceIn(OpenLessImeService.MIN_KEYBOARD_RAISE_DP, OpenLessImeService.MAX_KEYBOARD_RAISE_DP)
val footprint = buildKeyboardFootprintPreview()
fun refreshFootprint() = footprint.setSizes(heightDp, raiseDp)
val appearanceDock = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(20), dp(8), dp(20), dp(4))
setBackgroundColor(tone(Color.rgb(30, 30, 30), Color.rgb(245, 245, 247)))
}
appearanceDock.addView(sectionLabel(ui("键盘外观(下方为 1:1 预览)", "Keyboard appearance (1:1 preview below)")))
appearanceDock.addView(
sliderRow(
label = ui("按键区高度(拉伸)", "Key area height (stretch)"),
min = OpenLessImeService.MIN_KEYBOARD_HEIGHT_DP,
max = OpenLessImeService.MAX_KEYBOARD_HEIGHT_DP,
current = heightDp,
onChange = { value ->
heightDp = value
prefs.edit().putInt(OpenLessImeService.PREF_KEYBOARD_HEIGHT_DP, value).apply()
refreshFootprint()
},
),
)
appearanceDock.addView(
sliderRow(
label = ui("整体抬高(底部留白)", "Raise (bottom gap)"),
min = OpenLessImeService.MIN_KEYBOARD_RAISE_DP,
max = OpenLessImeService.MAX_KEYBOARD_RAISE_DP,
current = raiseDp,
onChange = { value ->
raiseDp = value
prefs.edit().putInt(OpenLessImeService.PREF_KEYBOARD_RAISE_DP, value).apply()
refreshFootprint()
},
),
)
root.addView(
appearanceDock,
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT),
)
root.addView(
footprint.root,
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT),
)
refreshFootprint()
// Paired with onCreate()'s setDecorFitsSystemWindows(false): now that
// the window draws edge-to-edge, this restores the padding the
// system used to apply automatically (status bar / nav bar / cutouts)
// AND — the actual point of going edge-to-edge here — adds the IME's
// own height as bottom padding whenever it's taller than the nav bar,
// so `scroll` (layout_weight=1) genuinely loses that much height
// while the keyboard is up — targetSdk 36 otherwise neutralizes the
// manifest's windowSoftInputMode="adjustResize" (the window no
// longer physically shrinks on its own), which is exactly why the
// 云笔记提交 URL/Token fields were unreachable once the keyboard
// covered them. Shrinking the viewport alone isn't enough on its
// own, though: if a field already had focus before the keyboard
// finished animating in, nothing re-triggers ScrollView's normal
// "bring the focused child into view" behavior on a pure padding
// change (that behavior only fires at the moment focus is first
// requested) — so once the inset is actually nonzero, explicitly
// scroll whatever's currently focused into the new, smaller
// viewport instead of leaving it wherever it happened to sit before.
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
view.setPadding(systemBars.left, systemBars.top, systemBars.right, maxOf(systemBars.bottom, imeBottom))
if (imeBottom > 0) {
val focused = view.findFocus()
if (focused != null) {
scroll.post {
val rect = android.graphics.Rect()
focused.getDrawingRect(rect)
content.offsetDescendantRectToMyCoords(focused, rect)
scroll.smoothScrollTo(0, rect.bottom - scroll.height + dp(16))
}
}
}
insets
}
// Gates the "进程重启统计(今天)" section further down (see its own
// guard) — off by default, since those counters are only meaningful
// for diagnosing a specific problem, not everyday reading. Read once
// here, up top, rather than re-reading prefs at the exact point it's
// used, so this row and the section it controls can never disagree
// within a single render of this page.
content.addView(sectionLabel(ui("主设置", "Main settings")))
val debugFeaturesEnabled = prefs.getBoolean("key_debug_features_enabled", false)
val debugRow = LinearLayout(this).apply { gravity = Gravity.CENTER_VERTICAL }
debugRow.addView(
TextView(this).apply {
text = ui("调试功能", "Debug features")
textSize = 15f
setTextColor(tone(Color.rgb(220, 220, 220), Color.rgb(40, 40, 44)))
},
LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f),
)
debugRow.addView(
Switch(this).apply {
isChecked = debugFeaturesEnabled
setOnCheckedChangeListener { _, checked ->
prefs.edit().putBoolean("key_debug_features_enabled", checked).apply()
// The restart-stats section's own visibility is decided
// once, above, when this page was built — rebuild it so
// toggling here shows/hides it immediately instead of
// only taking effect the next time this page opens.
setContentView(buildContent())
}
},
)
content.addView(
debugRow,
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(14)
},
)
content.addView(sectionLabel(ui("震动反馈", "Haptic feedback")))
val enabledRow = LinearLayout(this).apply { gravity = Gravity.CENTER_VERTICAL }
enabledRow.addView(
TextView(this).apply {
text = ui("按键震动", "Key vibration")
textSize = 15f
setTextColor(tone(Color.rgb(220, 220, 220), Color.rgb(40, 40, 44)))
},
LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f),
)
enabledRow.addView(
Switch(this).apply {
isChecked = prefs.getBoolean("key_haptic_enabled", true)
setOnCheckedChangeListener { _, checked -> prefs.edit().putBoolean("key_haptic_enabled", checked).apply() }
},
)
content.addView(
enabledRow,
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(14)
},
)
content.addView(sectionLabel(ui("英文键盘", "English keyboard")))
val englishSuggestionsRow = LinearLayout(this).apply { gravity = Gravity.CENTER_VERTICAL }
englishSuggestionsRow.addView(
TextView(this).apply {
text = ui("英文单词提示", "English word suggestions")
textSize = 15f
setTextColor(tone(Color.rgb(220, 220, 220), Color.rgb(40, 40, 44)))
},
LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f),
)
englishSuggestionsRow.addView(
Switch(this).apply {
isChecked = prefs.getBoolean("english_suggestions_enabled", true)
setOnCheckedChangeListener { _, checked -> prefs.edit().putBoolean("english_suggestions_enabled", checked).apply() }
},
)
content.addView(
englishSuggestionsRow,
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(14)
},
)
// Amplitude's 255 ceiling is Android's own VibrationEffect max, not a
// choice made here — the hardware/API can't go any stronger than
// that regardless of what this slider allows. Duration's ceiling
// started at 500ms, halved to 250, then halved again to 125 so the
// same slider width covers a quarter of the original range, for
// finer-grained adjustment.
var currentAmplitude = prefs.getInt("key_haptic_amplitude", 55).coerceIn(1, 255)
var currentDurationMs = prefs.getLong("key_haptic_duration_ms", 12L).toInt().coerceIn(1, 125)
// No separate test button — letting go of either slider fires one
// vibration with the values as they now stand, so adjusting and
// feeling the result is a single motion.
content.addView(
sliderRow(
label = ui("震动强度(系统上限)", "Intensity (platform ceiling)"),
min = 1,
max = 255,
current = currentAmplitude,
onChange = { value ->
currentAmplitude = value
prefs.edit().putInt("key_haptic_amplitude", value).apply()
},
onRelease = { fireTestVibration(currentAmplitude, currentDurationMs) },
),
)
content.addView(
sliderRow(
label = ui("震动时长", "Duration"),
min = 1,
max = 125,
current = currentDurationMs,
onChange = { value ->
currentDurationMs = value
prefs.edit().putLong("key_haptic_duration_ms", value.toLong()).apply()
},
onRelease = { fireTestVibration(currentAmplitude, currentDurationMs) },
),
)
content.addView(sectionLabel(ui("后台运行", "Background")))
// OEM background-task killers (observed on-device: this app's own
// settings Activity gets reclaimed by the system 16+ times/day even
// with a foreground service running) largely ignore that
// protection but do respect the standard "ignore battery
// optimizations" exemption — offering a direct link to it here is
// the most effective single thing a user can do about the restart
// counts below. isIgnoringBatteryOptimizations() re-reads live each
// time this screen builds, so returning here after granting it in
// system settings shows the up-to-date state without extra wiring.
val powerManager = getSystemService(Context.POWER_SERVICE) as android.os.PowerManager
if (powerManager.isIgnoringBatteryOptimizations(packageName)) {
content.addView(
TextView(this).apply {
text = ui("已加入电池优化白名单", "Already exempt from battery optimization")
textSize = 13f
setTextColor(tone(Color.rgb(134, 239, 172), Color.rgb(21, 128, 61)))
},
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(14)
},
)
} else {
content.addView(
TextView(this).apply {
text = ui(
"系统电量管理可能频繁回收键盘的后台进程,导致设置页偶尔黑屏或响应变慢。加入电池优化白名单可以减少这种情况——效果因系统而异。",
"The system's battery manager may repeatedly reclaim the keyboard's background process, occasionally causing a black settings screen or slow responses. Exempting it from battery optimization can reduce this — effectiveness varies by device.",
)
textSize = 13f
setTextColor(tone(Color.rgb(200, 200, 200), Color.rgb(70, 70, 75)))
},
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(8)
},
)
content.addView(
TextView(this).apply {
text = ui("去允许后台活动 →", "Allow background activity →")
textSize = 15f
setTypeface(typeface, android.graphics.Typeface.BOLD)
setTextColor(tone(Color.rgb(94, 234, 212), Color.rgb(15, 118, 110)))
isClickable = true
setOnClickListener {
val direct = android.content.Intent(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = android.net.Uri.parse("package:$packageName")
}
val fallback = android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:$packageName")
}
runCatching { startActivity(direct) }
// A handful of heavily customized OEM systems
// block or silently no-op this specific system
// intent — the general app-details screen at
// least lands the user in the right area, one
// tap further from the actual toggle, instead
// of nothing happening on tap.
.onFailure { runCatching { startActivity(fallback) } }
}
},
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(14)
},
)
}
val monospace = android.graphics.Typeface.MONOSPACE
// Diagnostic-only — hidden unless "调试功能" above is on (see that
// switch's own comment). These counters help debug a specific
// problem; they're noise for everyday reading otherwise.
if (debugFeaturesEnabled) {
content.addView(sectionLabel(ui("进程重启统计(今天)", "Process restarts (today)")))
// Short, purposefully un-translated keys (not meant to be pretty —
// meant to be pasted into a screenshot and read back verbatim).
// All reset to 0 whenever OpenLessBuildInfo.VERSION changes (see
// OpenLessApplication.resetRestartStatsOnVersionBump()), so these
// are always "since this build was installed", not lifetime totals:
// main/access - raw restarts of the main / :accessibility process
// sticky - OpenLessRuntimeService.onStartCommand() got a
// null Intent: Android's own restart-after-death
// signal for a START_STICKY service, the strongest
// evidence the whole process was actually killed
// warmup - OpenLessBackendWarmupActivity.ensureBackendReady()
// found the backend not registered and launched
// the warmup Activity
// mictap - user tapped the mic and toggleDictation() found
// the backend not ready (the user-visible symptom)
// actkill - OpenLessBackendWarmupActivity.onDestroy() fired,
// total across all reasons below (doesn't
// necessarily mean the process itself died)
// actkill_config - onDestroy() from a configuration change
// (rotation/density/locale) — expected, harmless
// actkill_finishing - isFinishing was true (unexpected; this
// Activity never calls finish() on itself
// deliberately)
// actkill_os - none of the above: the only sub-category that
// is actually the system reclaiming this task
// rtexit - Tauri's RunEvent::Exit actually fired despite
// ExitRequested being prevented (see
// mobile_runtime.rs) — should stay at 0 if that fix
// is holding
// unclean - previous main-process session never reached
// OpenLessImeService.onDestroy() (best-effort
// crash/force-stop signal, can't tell those apart)
// Chinese gloss for each key — just enough to read at a glance
// without cross-referencing the doc comment above.
val restartCategories = listOf(
Triple(OpenLessProcessRestartStats.MAIN, "main", "主进程"),
Triple(OpenLessProcessRestartStats.ACCESSIBILITY, "access", "无障碍进程"),
Triple("sticky", "sticky", "系统杀后恢复"),
Triple("warmup", "warmup", "后端唤醒"),
Triple("mictap", "mictap", "点击时未就绪"),
Triple("actkill", "actkill", "界面被回收(合计)"),
Triple("actkill_config", " ├config", "· 配置变化(无害)"),
Triple("actkill_finishing", " ├finish", "· isFinishing(异常)"),
Triple("actkill_os", " └os", "· 真正被系统回收"),
Triple("rtexit", "rtexit", "后端异常退出"),
Triple("unclean", "unclean", "上次异常退出"),
Triple("heartbeat", "heartbeat", "心跳自愈"),
Triple("stuckwindow", "stuckwindow", "设置窗口卡死自重启"),
)
for ((key, label, gloss) in restartCategories) {
content.addView(
TextView(this).apply {
val count = OpenLessProcessRestartStats(this@OpenLessKeyboardSettingsActivity, key).today()
text = label.padEnd(10) + count.toString().padEnd(4) + gloss
textSize = 13f
typeface = monospace
setTextColor(tone(Color.rgb(200, 200, 200), Color.rgb(70, 70, 75)))
},
)
}
content.addView(View(this), LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(14)))
}
// "个人偏好数据": read-only counters for the two local, on-device-only
// learning stores that make repeated input steadily rank better
// (笔画/拼音 both benefit — see each row's own comment). No toggle
// here — matches this section's existing convention of pure
// display; the underlying preferences (strokeUsageEnabled etc.)
// live in the app's own WebView settings, not this native page.
content.addView(sectionLabel(ui("个人偏好数据", "Personal preference data")))
// 笔画输入的个人调频数据:记录"这个笔画码你选过哪个字",越用越靠前
// 排序,不影响词库本身,也不会同步到云端。
val personalFrequency = StrokeUserFrequency(this)
content.addView(
TextView(this).apply {
text = ui(
"笔画调频:已记录 ${personalFrequency.size()} / ${personalFrequency.capacity()} 条",
"Stroke ranking: ${personalFrequency.size()} / ${personalFrequency.capacity()} entries recorded",
)
textSize = 14f
setTextColor(tone(Color.rgb(200, 200, 200), Color.rgb(70, 70, 75)))
},
)
// 简拼优选:不是每次输入都记一条,是"连续两次直接打拼音上屏"的组合
// (比如先打 zg 选中国,紧接着打 rm 选人民)达到 3 次后才计入这里——
// 见 LitePinyinLearnedPhrases 的文档注释。这里只显示已经达标、正在
// 生效的组合数,未达标的候选不计入(避免这个数字本身产生误导)。
val learnedPhrases = LitePinyinLearnedPhrases(this)
content.addView(
TextView(this).apply {
text = ui(
"简拼优选:已生效 ${learnedPhrases.promotedCount()} 条",
"Pinyin combo learning: ${learnedPhrases.promotedCount()} promoted",
)
textSize = 14f
setTextColor(tone(Color.rgb(200, 200, 200), Color.rgb(70, 70, 75)))
setPadding(0, 0, 0, dp(14))
},
)
// 话筒右划进入"云笔记":录音经 LLM 整理后的文字(跟正常上屏用的是
// 同一条润色流程——见 OpenLessImeService.handleImeTextReady())会以
// JSON POST 到这里配置的地址,不插入任何输入框,也不在本机留存。
// 两项都填了才会真的提交——留空时只会在状态栏提示去设置里补上,不会
// 静默失败。
content.addView(sectionLabel(ui("云笔记提交", "Cloud notes webhook")))
content.addView(
TextView(this).apply {
text = ui(
"话筒右划进入「云笔记」模式:录音经过整理后的文字会提交到下面的地址,不插入输入框,也不保存在本机。",
"Swipe the mic right to enter Cloud notes mode: the polished transcript is POSTed to the address below instead of being inserted — nothing is kept on this device either.",
)
textSize = 12f
setTextColor(tone(Color.rgb(150, 150, 150), Color.rgb(110, 110, 115)))
setPadding(0, 0, 0, dp(10))
},
)
content.addView(
textFieldRow(
label = ui("提交地址", "Submit URL"),
hint = "https://example.com/capture_ingest.php",
initial = prefs.getString("key_cloud_note_webhook_url", "") ?: "",
onChange = { prefs.edit().putString("key_cloud_note_webhook_url", it).apply() },
),
)
content.addView(
textFieldRow(
label = "Token",
hint = ui("输入法专用 Token", "IME-only token"),
initial = prefs.getString("key_cloud_note_webhook_token", "") ?: "",
isSecret = true,
onChange = { prefs.edit().putString("key_cloud_note_webhook_token", it).apply() },
),
)
// "导出/导入配置": bundles everything OpenLessSettingsExport knows how
// to read (see that object's own Category enum) into one JSON file,
// or restores one — for moving personal settings to another device
// without retyping everything. Plain JSON, no encryption, by
// explicit product decision — the warning text below is the only
// protection against an accidentally-shared file containing a
// Cloud notes token or (if that category is checked) a raw ASR/LLM
// API key.
content.addView(sectionLabel(ui("导出 / 导入配置", "Export / Import settings")))
content.addView(
TextView(this).apply {
text = ui(
"把云笔记地址、震动参数、笔画调频、简拼优选、ASR/LLM/风格包的选择(以及可选的 API Key)打包成一个文件,方便迁移到另一台设备。文件是明文 JSON——如果勾选了 API Key,文件里会有裸的密钥,请妥善保管。",
"Bundles the Cloud notes address, haptic settings, stroke ranking, pinyin combo learning, and ASR/LLM/style pack selection (plus optional API keys) into one file for moving to another device. The file is plain JSON — including API keys leaves them in the file in plaintext, so keep it safe.",
)
textSize = 12f
setTextColor(tone(Color.rgb(150, 150, 150), Color.rgb(110, 110, 115)))
setPadding(0, 0, 0, dp(10))
},
)
val exportImportRow = LinearLayout(this).apply { gravity = Gravity.CENTER_VERTICAL }
exportImportRow.addView(
TextView(this).apply {
text = ui("导出配置 →", "Export settings →")
textSize = 15f
setTypeface(typeface, android.graphics.Typeface.BOLD)
setTextColor(tone(Color.rgb(94, 234, 212), Color.rgb(15, 118, 110)))
isClickable = true
setOnClickListener { showExportCategoryDialog() }
},
LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f),
)
exportImportRow.addView(
TextView(this).apply {
text = ui("导入配置 →", "Import settings →")
textSize = 15f
setTypeface(typeface, android.graphics.Typeface.BOLD)
setTextColor(tone(Color.rgb(94, 234, 212), Color.rgb(15, 118, 110)))
isClickable = true
setOnClickListener { launchImportPicker() }
},
LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f),
)
content.addView(
exportImportRow,
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(16)
},
)
// build_first_seen_wall_time is written by OpenLessApplication's
// resetRestartStatsOnVersionBump() at the exact moment it last
// zeroed the restart-cause counters below — i.e. "counting since
// when" for whatever counts are on screen right now, so a
// screenshot of this page carries both together.
val buildFirstSeenAt = getSharedPreferences("openless_runtime", Context.MODE_PRIVATE)
.getLong("build_first_seen_wall_time", 0L)
val installedAtText = if (buildFirstSeenAt > 0L) {
android.text.format.DateFormat.format("yyyy-MM-dd HH:mm", buildFirstSeenAt)
} else {
"?"
}
content.addView(
TextView(this).apply {
text = "${ui("构建版本", "Build")} ${OpenLessBuildInfo.VERSION} ${ui("安装于", "installed")} $installedAtText"
textSize = 11f
typeface = monospace
setTextColor(tone(Color.rgb(120, 120, 120), Color.rgb(150, 150, 155)))
},
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
topMargin = dp(16)
},
)
return root
}
private fun showExportCategoryDialog() {
val categories = OpenLessSettingsExport.Category.entries.toTypedArray()
val labels = categories.map { ui(it.labelZh, it.labelEn) }.toTypedArray()
val checked = BooleanArray(categories.size) { true }
android.app.AlertDialog.Builder(this)
.setTitle(ui("选择要导出的内容", "Choose what to export"))
.setMultiChoiceItems(labels, checked) { _, which, isChecked -> checked[which] = isChecked }
.setPositiveButton(ui("导出", "Export")) { _, _ ->
val selected = categories.filterIndexed { index, _ -> checked[index] }.toSet()
if (selected.isEmpty()) {
Toast.makeText(this, ui("没有选择任何内容", "Nothing selected"), Toast.LENGTH_SHORT).show()
return@setPositiveButton
}
Thread {
val exported = runCatching { OpenLessSettingsExport.export(this, selected) }
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
exported.onSuccess { json ->
pendingExportJson = json
val timestamp = android.text.format.DateFormat.format("yyyyMMdd-HHmm", System.currentTimeMillis())
val intent = android.content.Intent(android.content.Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(android.content.Intent.CATEGORY_OPENABLE)
type = "application/json"
putExtra(android.content.Intent.EXTRA_TITLE, "openless-settings-$timestamp.json")
}
runCatching { startActivityForResult(intent, requestCodeExportSave) }.onFailure {
pendingExportJson = null
Toast.makeText(this, ui("找不到文件管理器", "No file manager available"), Toast.LENGTH_LONG).show()
}
}.onFailure {
Toast.makeText(this, ui("导出失败,请检查服务和凭据存储", "Export failed; check the service and credential store"), Toast.LENGTH_LONG).show()
}
}
}.start()
}
.setNegativeButton(ui("取消", "Cancel"), null)
.show()
}
private fun launchImportPicker() {
val intent = android.content.Intent(android.content.Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(android.content.Intent.CATEGORY_OPENABLE)
type = "application/json"
}
runCatching { startActivityForResult(intent, requestCodeImportOpen) }
.onFailure { Toast.makeText(this, ui("找不到文件选择器", "No file picker available"), Toast.LENGTH_SHORT).show() }
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode != RESULT_OK || data == null) return
when (requestCode) {
requestCodeExportSave -> {
val uri = data.data
val json = pendingExportJson
pendingExportJson = null
if (uri == null || json == null) return
val wrote = runCatching {
checkNotNull(contentResolver.openOutputStream(uri)).use { it.write(json.toByteArray(Charsets.UTF_8)) }
}.isSuccess
Toast.makeText(
this,
if (wrote) ui("已导出", "Exported") else ui("导出失败", "Export failed"),
Toast.LENGTH_SHORT,
).show()
}
requestCodeImportOpen -> {
val uri = data.data ?: return
Thread {
val json = OpenLessContentReader.readBytes(this, uri.toString(), 4 * 1024 * 1024)?.toString(Charsets.UTF_8)
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
if (json.isNullOrBlank()) {
Toast.makeText(this, ui("读取文件失败或文件超过 4 MiB", "Read failed or file exceeds 4 MiB"), Toast.LENGTH_SHORT).show()
} else showImportCategoryDialog(json)
}
}.start()
}
}
}
private fun showImportCategoryDialog(json: String) {
val present = OpenLessSettingsExport.categoriesPresent(json)
if (present.isEmpty()) {
Toast.makeText(this, ui("这不是一个有效的配置文件", "Not a valid settings file"), Toast.LENGTH_SHORT).show()
return
}
val categories = present.toList()
val labels = categories.map { ui(it.labelZh, it.labelEn) }.toTypedArray()
val checked = BooleanArray(categories.size) { true }
android.app.AlertDialog.Builder(this)
.setTitle(ui("选择要导入的内容", "Choose what to import"))
.setMultiChoiceItems(labels, checked) { _, which, isChecked -> checked[which] = isChecked }
.setPositiveButton(ui("导入", "Import")) { _, _ ->
val selected = categories.filterIndexed { index, _ -> checked[index] }.toSet()
if (selected.isEmpty()) {
Toast.makeText(this, ui("没有选择任何内容", "Nothing selected"), Toast.LENGTH_SHORT).show()
return@setPositiveButton
}
Thread {
val imported = runCatching { OpenLessSettingsExport.import(this, json, selected) }
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
val result = imported.getOrNull()
val message = if (result == null) ui("配置文件无效,导入失败", "Invalid settings file; import failed")
else buildString {
append(ui("已导入 ${result.applied.size} 项", "Imported ${result.applied.size} categories"))
for ((category, reason) in result.errors) {
append('\n').append(ui(category.labelZh, category.labelEn)).append(": ").append(reason)
}
}
android.app.AlertDialog.Builder(this).setMessage(message)
.setPositiveButton(android.R.string.ok, null).show()
setContentView(buildContent())
}
}.start()
}
.setNegativeButton(ui("取消", "Cancel"), null)
.show()
}
/** Fires a one-shot vibration with the sliders' current (already-saved) values, so a change is felt immediately. */
private fun fireTestVibration(amplitude: Int, durationMs: Int) {
runCatching {
val vibrator = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
(getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as android.os.VibratorManager).defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(Context.VIBRATOR_SERVICE) as android.os.Vibrator
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
vibrator.vibrate(android.os.VibrationEffect.createOneShot(durationMs.toLong(), amplitude))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(durationMs.toLong())
}
}
}
private fun sectionLabel(text: String): View = TextView(this).apply {
this.text = text
textSize = 12f
setTextColor(tone(Color.rgb(150, 150, 150), Color.rgb(110, 110, 115)))
setPadding(0, 0, 0, dp(8))
}
/**
* Full-width 1:1 IME footprint at the bottom of settings. Key-area block
* uses the stretch height; raise strip is empty lift space below keys.
*/
private fun buildKeyboardFootprintPreview(): KeyboardFootprintPreview {
val caption = TextView(this).apply {
textSize = 11f
gravity = Gravity.CENTER
setTextColor(tone(Color.rgb(160, 160, 160), Color.rgb(100, 100, 105)))
setPadding(0, dp(6), 0, dp(4))
}
val keysBlock = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setBackgroundColor(tone(Color.rgb(48, 48, 48), Color.rgb(242, 242, 246)))
setPadding(dp(12), dp(10), dp(12), dp(10))
// Three fake key rows so stretch is visually obvious.
repeat(3) { rowIndex ->
val row = LinearLayout(this@OpenLessKeyboardSettingsActivity).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
}
repeat(10) {
row.addView(
View(this@OpenLessKeyboardSettingsActivity).apply {
setBackgroundColor(tone(Color.rgb(70, 70, 70), Color.rgb(255, 255, 255)))
},
LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f).apply {
marginStart = dp(2)
marginEnd = dp(2)
},
)
}
addView(
row,
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f).apply {
if (rowIndex > 0) topMargin = dp(6)
},
)
}
}
val raiseBlock = FrameLayout(this).apply {
setBackgroundColor(tone(Color.rgb(36, 36, 36), Color.rgb(220, 220, 224)))
addView(
TextView(this@OpenLessKeyboardSettingsActivity).apply {
text = ui("抬高", "Raise")
textSize = 11f
gravity = Gravity.CENTER
setTextColor(tone(Color.rgb(140, 140, 140), Color.rgb(120, 120, 125)))
},
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
),
)
}
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(tone(Color.rgb(22, 22, 22), Color.rgb(230, 230, 234)))
addView(caption, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
addView(keysBlock, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(OpenLessImeService.DEFAULT_KEYBOARD_HEIGHT_DP)))
addView(raiseBlock, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0))
}
return KeyboardFootprintPreview(root, caption, keysBlock, raiseBlock)
}
private inner class KeyboardFootprintPreview(
val root: LinearLayout,
private val caption: TextView,
private val keysBlock: View,
private val raiseBlock: View,
) {
fun setSizes(heightDp: Int, raiseDp: Int) {
keysBlock.layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(heightDp),
)
raiseBlock.layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(raiseDp),
)
raiseBlock.visibility = if (raiseDp > 0) View.VISIBLE else View.GONE
caption.text = ui(
"实时预览 · 按键区 ${heightDp}dp · 抬高 ${raiseDp}dp · 共 ${heightDp + raiseDp}dp",
"Live preview · keys ${heightDp}dp · raise ${raiseDp}dp · total ${heightDp + raiseDp}dp",
)
root.requestLayout()
}
}
/** One labeled slider row. Reusable as more settings rows get added here. */
private fun sliderRow(label: String, min: Int, max: Int, current: Int, onChange: (Int) -> Unit, onRelease: (() -> Unit)? = null): View {
// Computed before building the SeekBar itself, since inside that
// view's own apply{} block an unqualified "max" would resolve to
// SeekBar's own max property (shadowing this function's max: Int
// parameter), not the value intended here.
val range = (max - min).coerceAtLeast(1)
val initialProgress = (current - min).coerceIn(0, range)
val row = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(14)
}
}
val labelView = TextView(this).apply {
text = "$label · Max $max Set:$current"
textSize = 14f
setTextColor(tone(Color.rgb(200, 200, 200), Color.rgb(70, 70, 75)))
}
row.addView(labelView)
row.addView(
SeekBar(this).apply {
this.max = range
this.progress = initialProgress
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) {
val value = progress + min
labelView.text = "$label · Max $max Set:$value"
onChange(value)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
override fun onStopTrackingTouch(seekBar: SeekBar?) {
onRelease?.invoke()
}
})
},
)
return row
}
/** One labeled single-line text field, auto-saving on every keystroke (matches every other row on this page — no separate save button). Used by the "云笔记提交" section for its URL/token; reusable for any future free-text setting. */
private fun textFieldRow(label: String, hint: String, initial: String, isSecret: Boolean = false, onChange: (String) -> Unit): View {
val row = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(14)
}
}
row.addView(
TextView(this).apply {
text = label
textSize = 14f
setTextColor(tone(Color.rgb(200, 200, 200), Color.rgb(70, 70, 75)))
setPadding(0, 0, 0, dp(4))
},
)
row.addView(
EditText(this).apply {
setText(initial)
this.hint = hint
textSize = 14f
isSingleLine = true
if (isSecret) {
inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
}
setTextColor(tone(Color.WHITE, Color.rgb(30, 30, 34)))
setHintTextColor(tone(Color.rgb(110, 110, 115), Color.rgb(170, 170, 175)))
addTextChangedListener(object : android.text.TextWatcher {
override fun afterTextChanged(s: android.text.Editable?) = onChange(s?.toString().orEmpty())
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
})
},
)
return row
}
}