Files
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

426 lines
20 KiB
Kotlin

package com.openless.app
import android.Manifest
import android.app.Activity
import android.app.ActivityManager
import android.app.Application
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.net.Uri
import android.util.Log
/** Registers activity lifecycle hooks for overlay background trigger mode. */
class OpenLessApplication : Application() {
override fun onCreate() {
super.onCreate()
OpenLessAppContext.initialize(this)
// #region agent log
// Baseline JNI Context so overlay/dictation notifies work even when
// neither RuntimeService (IME) nor OverlayService has started yet.
runCatching {
OpenLessNative.nativeRegisterActivityContext(this)
Log.i(
"OpenLessDbg58c22b",
"""{"sessionId":"58c22b","hypothesisId":"A","location":"OpenLessApplication.onCreate","message":"registered application context baseline","data":{"hasCtx":${OpenLessNative.nativeHasRegisteredActivityContext()}},"timestamp":${System.currentTimeMillis()}}""",
)
}.onFailure { error ->
Log.w(
"OpenLessDbg58c22b",
"""{"sessionId":"58c22b","hypothesisId":"A","location":"OpenLessApplication.onCreate","message":"application register failed","data":{"error":"${error.message}"},"timestamp":${System.currentTimeMillis()}}""",
)
}
// #endregion
if (isMainProcess()) {
OpenLessShizukuBridge.initialize()
}
recordProcessRestart()
registerActivityLifecycleCallbacks(
object : ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
// Registering the with_android_env() JNI Context used
// to happen here for whichever MainActivity-family
// instance was created — moved to
// OpenLessRuntimeService.onCreate()/onDestroy() instead
// (see OpenLessNative.nativeRegisterActivityContext()'s
// doc comment): a Service that starts/stops in lockstep
// with the IME being active is a far more stable
// registrant than an Activity that spends nearly its
// whole life backgrounded via moveTaskToBack() and can
// be reclaimed by the OS at any point during that.
}
override fun onActivityStarted(activity: Activity) {
// Must use `is MainActivity` — launcher opens
// OpenLessBackendWarmupActivity (subclass), whose class name
// does NOT end with "MainActivity", so endsWith() never fired
// overlay show/hide or permission prompts.
if (activity is MainActivity) {
// #region agent log
Log.i(
"OpenLessDbg58c22b",
"""{"sessionId":"58c22b","hypothesisId":"F","location":"OpenLessApplication.onActivityStarted","message":"main-family started","data":{"cls":"${activity.javaClass.simpleName}","hasCtx":${runCatching { OpenLessNative.nativeHasRegisteredActivityContext() }.getOrDefault(false)}},"timestamp":${System.currentTimeMillis()}}""",
)
// #endregion
maybeRequestBatteryOptimizationExemption(activity)
maybeRequestNotificationPermission(activity)
maybeHideOverlayOnForeground()
}
}
override fun onActivityResumed(activity: Activity) {
// Settings are always opened through
// OpenLessBackendWarmupActivity (a MainActivity subclass),
// never MainActivity itself directly — an exact class
// check here meant this never matched in practice, so the
// interface-language poll never actually ran and the
// mirrored locale pref went stale at whatever it was on
// first install.
if (activity is MainActivity) {
watchWebViewMirroredState(activity)
}
}
override fun onActivityPaused(activity: Activity) {
if (activity is MainActivity) {
readInterfaceLanguage(activity)
readInterfaceTheme(activity)
localeHandler.removeCallbacksAndMessages(null)
}
}
override fun onActivityStopped(activity: Activity) {
if (activity is MainActivity) {
// #region agent log
Log.i(
"OpenLessDbg58c22b",
"""{"sessionId":"58c22b","hypothesisId":"F","location":"OpenLessApplication.onActivityStopped","message":"main-family stopped — maybe show overlay","data":{"cls":"${activity.javaClass.simpleName}","trigger":"${configuredOverlayTriggerMode()}"},"timestamp":${System.currentTimeMillis()}}""",
)
// #endregion
maybeShowOverlayOnBackground()
}
}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) =
Unit
override fun onActivityDestroyed(activity: Activity) {
// Unregistering the with_android_env() JNI Context used
// to happen here — see onActivityCreated() above and
// OpenLessRuntimeService, which now owns this instead.
}
}
)
}
private fun maybeShowOverlayOnBackground() {
val configured = configuredOverlayTriggerMode()
val shouldShow = configured == "background" || configured == "always"
if (!shouldShow) {
return
}
if (!canDrawOverlays()) {
// #region agent log
Log.w(
"OpenLessDbg58c22b",
"""{"sessionId":"58c22b","hypothesisId":"B","location":"OpenLessApplication.maybeShowOverlayOnBackground","message":"skip show — canDrawOverlays false","timestamp":${System.currentTimeMillis()}}""",
)
// #endregion
return
}
// #region agent log
Log.i(
"OpenLessDbg58c22b",
"""{"sessionId":"58c22b","hypothesisId":"B","location":"OpenLessApplication.maybeShowOverlayOnBackground","message":"sending ACTION_SHOW","data":{"trigger":"$configured"},"timestamp":${System.currentTimeMillis()}}""",
)
// #endregion
sendOverlayAction(OpenLessOverlayService.ACTION_SHOW)
}
private fun maybeHideOverlayOnForeground() {
if (configuredOverlayTriggerMode() == "always") {
if (canDrawOverlays()) {
sendOverlayAction(OpenLessOverlayService.ACTION_SHOW)
}
return
}
sendOverlayAction(OpenLessOverlayService.ACTION_HIDE)
}
private fun configuredOverlayTriggerMode(): String {
return OpenLessAndroidPreferences.overlayTriggerMode(this) ?: "background"
}
private fun canDrawOverlays(): Boolean {
return OpenLessPermissionBridge.canDrawOverlaysSafely(this)
}
private fun sendOverlayAction(action: String) {
try {
startService(
Intent(this, OpenLessOverlayService::class.java).apply {
this.action = action
}
)
} catch (error: Throwable) {
Log.w(TAG, "overlay action failed: $action", error)
}
}
// The interface language lives in the Tauri WebView, not preferences.json.
// Mirror only that setting for the native IME; never read editor content.
private val localeHandler = android.os.Handler(android.os.Looper.getMainLooper())
private fun watchWebViewMirroredState(activity: Activity) {
localeHandler.removeCallbacksAndMessages(null)
val poll = object : Runnable {
override fun run() {
if (activity.isFinishing || activity.isDestroyed) return
readInterfaceLanguage(activity)
readInterfaceTheme(activity)
localeHandler.postDelayed(this, 500L)
}
}
localeHandler.post(poll)
}
private fun readInterfaceLanguage(activity: Activity) {
fun findWebView(view: android.view.View): android.webkit.WebView? {
if (view is android.webkit.WebView) return view
if (view is android.view.ViewGroup) {
for (index in 0 until view.childCount) {
findWebView(view.getChildAt(index))?.let { return it }
}
}
return null
}
val webView = findWebView(activity.window.decorView) ?: return
webView.evaluateJavascript(
"(function(){return localStorage.getItem('ol.locale') || document.documentElement.lang || navigator.language;})()",
) { result ->
val locale = result.trim('"').lowercase(java.util.Locale.ROOT)
if (!locale.matches(Regex("[a-z]{2,3}(-[a-z0-9]{2,8})*"))) return@evaluateJavascript
val prefs = getSharedPreferences("openless_ime_ui", MODE_PRIVATE)
if (prefs.getString("locale", null) != locale) {
prefs.edit().putString("locale", locale).apply()
}
}
}
// The keyboard's light/dark palette follows the app's own theme setting
// (Settings > Appearance), not the raw OS setting. Read directly from
// the same two sources themeMode.ts's resolveTheme() uses (the stored
// 'ol.theme' preference, falling back to prefers-color-scheme for
// "system") instead of the `data-ol-theme` DOM attribute it derives
// from: the attribute only reflects reality once applyThemeMode() has
// actually run in this tick, and a poll landing mid-render/mid-navigation
// could read it before that — which showed up as the keyboard flashing
// the wrong theme until the next rebuild "self-corrected" it. localStorage
// and matchMedia are queryable immediately regardless of render timing.
private fun readInterfaceTheme(activity: Activity) {
fun findWebView(view: android.view.View): android.webkit.WebView? {
if (view is android.webkit.WebView) return view
if (view is android.view.ViewGroup) {
for (index in 0 until view.childCount) {
findWebView(view.getChildAt(index))?.let { return it }
}
}
return null
}
val webView = findWebView(activity.window.decorView) ?: return
webView.evaluateJavascript(
"""
(function(){
try {
var pref = window.localStorage.getItem('ol.theme');
if (pref !== 'light' && pref !== 'dark' && pref !== 'system') pref = 'system';
if (pref === 'light') return 'light';
if (pref === 'dark') return 'dark';
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
} catch (e) {
return 'unknown';
}
})()
""".trimIndent(),
) { result ->
val theme = result.trim('"')
if (theme != "dark" && theme != "light") return@evaluateJavascript
val prefs = getSharedPreferences("openless_ime_ui", MODE_PRIVATE)
if (prefs.getString("theme_mode", null) != theme) {
prefs.edit().putString("theme_mode", theme).apply()
}
}
}
private fun maybeRequestBatteryOptimizationExemption(activity: Activity) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val power = getSystemService(POWER_SERVICE) as? PowerManager ?: return
// Re-checked against live system state every time, not gated by a
// one-shot "already prompted" flag: some OEMs (OnePlus/ColorOS
// observed) silently revert this exemption back to "optimized" on
// their own, and a one-time flag would then never prompt again even
// though the app is no longer actually exempt.
if (power.isIgnoringBatteryOptimizations(packageName)) return
val prefs = getSharedPreferences("openless_runtime", MODE_PRIVATE)
val now = System.currentTimeMillis()
val lastPrompt = prefs.getLong("battery_optimization_prompted_at", 0L)
if (now >= lastPrompt && now - lastPrompt < BATTERY_PROMPT_COOLDOWN_MS) return
prefs.edit().putLong("battery_optimization_prompted_at", now).apply()
runCatching {
activity.startActivity(
Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:$packageName"),
)
)
}.onFailure { error ->
Log.w(TAG, "battery optimization exemption request failed", error)
}
}
// POST_NOTIFICATIONS (API 33+) is never auto-granted, and
// OpenLessBackendWarmupActivity only asks for it when the settings UI is
// opened explicitly — a user who only ever invokes the IME via the
// keyboard switcher, without ever tapping the launcher icon, could go
// through that path forever without the request ever firing. Asking
// here too, on any real (launcher) app open, catches that case.
private fun maybeRequestNotificationPermission(activity: Activity) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
if (activity.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
return
}
runCatching {
activity.requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_POST_NOTIFICATIONS)
}.onFailure { error ->
Log.w(TAG, "notification permission request failed", error)
}
}
private fun isMainProcess(): Boolean {
val processName = currentProcessName() ?: return true
return processName == packageName
}
// OpenLessApplication.onCreate() runs once per OS process this
// application object is forked into — not just the main one — so this
// is the one place both the main process (IME + Tauri backend) and the
// separate ":accessibility" process (OpenLessAccessibilityService) can
// each be counted under their own key. Any other/unexpected process
// name is intentionally left unrecorded rather than guessed at.
private fun recordProcessRestart() {
val processKey = when (currentProcessName() ?: packageName) {
packageName -> OpenLessProcessRestartStats.MAIN
"$packageName:accessibility" -> OpenLessProcessRestartStats.ACCESSIBILITY
else -> return
}
// Runs before this run's own recordStart() below, so a fresh
// build's first start isn't immediately wiped by its own reset.
if (processKey == OpenLessProcessRestartStats.MAIN) {
resetRestartStatsOnVersionBump()
}
OpenLessProcessRestartStats(this, processKey).recordStart()
if (processKey == OpenLessProcessRestartStats.MAIN) {
recordUncleanShutdownIfAny()
}
}
// Best-effort "did the previous main-process session end cleanly"
// check: OpenLessImeService.onDestroy() clears "session_alive" on any
// ordinary teardown (keyboard switched away from, app force-stopped).
// An abrupt process kill — native crash, OOM — skips onDestroy()
// entirely and leaves it set, so finding it still set here means the
// previous run did not end cleanly. Can't tell a crash apart from a
// deliberate force-stop this way, but both are worth surfacing.
private fun recordUncleanShutdownIfAny() {
val prefs = getSharedPreferences("openless_runtime", MODE_PRIVATE)
if (prefs.getBoolean("session_alive", false)) {
OpenLessProcessRestartStats(this, "unclean").recordStart()
}
prefs.edit().putBoolean("session_alive", true).apply()
}
// A fresh install/build makes every one of these counts so far
// meaningless to keep — they're tracking whether *this* build has been
// getting killed/crashing, not history from whatever was installed
// before. Compares against OpenLessBuildInfo.VERSION (bumped by hand
// before each debug build during this testing cycle) rather than the
// real app version, since that's what actually changes between the
// installs being compared.
private fun resetRestartStatsOnVersionBump() {
val prefs = getSharedPreferences("openless_runtime", MODE_PRIVATE)
val lastVersion = prefs.getString("last_seen_build_version", null)
if (lastVersion != OpenLessBuildInfo.VERSION) {
for (category in ALL_RESTART_CATEGORIES) {
OpenLessProcessRestartStats(this, category).resetToday()
}
// Wall-clock timestamp of the moment this build's counters were
// last zeroed — shown on the keyboard settings screen so a
// screenshot of the restart-cause counts also carries "counting
// since when", not just the raw totals.
prefs.edit()
.putString("last_seen_build_version", OpenLessBuildInfo.VERSION)
.putLong("build_first_seen_wall_time", System.currentTimeMillis())
.apply()
}
}
private fun currentProcessName(): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
return Application.getProcessName()
}
val pid = android.os.Process.myPid()
val activityManager = getSystemService(ACTIVITY_SERVICE) as? ActivityManager ?: return null
return activityManager.runningAppProcesses?.firstOrNull { it.pid == pid }?.processName
}
companion object {
private const val TAG = "OpenLessApplication"
private const val BATTERY_PROMPT_COOLDOWN_MS = 3L * 24 * 60 * 60 * 1000
private const val REQUEST_POST_NOTIFICATIONS = 9103
// Kept in sync by hand with every restart-cause key actually used
// across the app (OpenLessRuntimeService's "sticky"/"actkill"/its
// "actkill_<reason>" sub-categories/"rtexit",
// OpenLessBackendWarmupActivity's "warmup", OpenLessImeService's
// "mictap", and this file's own MAIN/ACCESSIBILITY/"unclean") —
// resetRestartStatsOnVersionBump() needs the full list to zero
// everything out on a fresh build. The actkill_* entries were
// missing here for a while: "actkill" itself got zeroed on every
// version bump but its sub-categories didn't, so they kept
// accumulating across bumps within the same day while the total
// reset out from under them — e.g. actkill_finishing reading
// higher than actkill itself, which should never happen since
// both are only ever incremented together (OpenLessRuntimeService).
// actkill_self was one of the four sub-categories added along with
// this fix, then removed again shortly after (along with the
// recreate()-based settings-reload escalation it tracked, see
// OpenLessBackendWarmupActivity's onDestroy()) — this app never
// calls Activity.recreate() on itself any more, so that reason can
// no longer occur.
private val ALL_RESTART_CATEGORIES = listOf(
OpenLessProcessRestartStats.MAIN,
OpenLessProcessRestartStats.ACCESSIBILITY,
"sticky",
"warmup",
"mictap",
"actkill",
"actkill_config",
"actkill_finishing",
"actkill_os",
"rtexit",
"unclean",
"heartbeat",
// OpenLessBackendWarmupActivity.restartProcessAsLastResort() —
// the "main" webview window's Tauri-side label got permanently
// stuck (see its doc comment) and this self-triggered kill was
// the only way out short of the user force-stopping manually.
"stuckwindow",
)
}
}