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>
930 lines
48 KiB
Kotlin
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
|
|
}
|
|
}
|