Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ jobs:
- name: Check Android updater pubkey matches tauri.conf.json
run: npm run check:android-updater-pubkey

# arm64 Android links sherpa-onnx's shared libraries (local ASR). Its
# build script finds them through SHERPA_ONNX_LIB_DIR and has no Android
# prebuilt download of its own.
- name: Prepare sherpa-onnx Android libraries
run: echo "SHERPA_ONNX_LIB_DIR=$(node scripts/sherpa-onnx-android.mjs)" >> "$GITHUB_ENV"

- name: Check Tauri backend (Android target)
run: cargo check --locked --manifest-path src-tauri/Cargo.toml --target aarch64-linux-android

Expand Down
58 changes: 56 additions & 2 deletions openless-all/app/android/kotlin/OpenLessImeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,14 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt
// voiceButton/status below.
private var backendLinkIndicator: View? = null
private var backendLinkPulseAnimator: android.animation.ObjectAnimator? = null
// Which recognizer the next dictation uses, as nativeAsrRoute() reports
// it ("cloud", "local", "localRaw", "unavailable"). The idle dot shows
// local in dark green and pure-local raw output (no usable polish
// channel, or no network to reach it) in light white. Refreshed off the
// main thread by refreshAsrRoute().
private var asrRoute = "cloud"
private var networkAvailable = true
private val asrRouteRefreshing = java.util.concurrent.atomic.AtomicBoolean(false)
// Updated only by the heartbeat check (see startBackendHeartbeat()),
// not by the normal recording/processing/done states — those already
// take priority in updateBackendLinkIndicator(). False means the most
Expand Down Expand Up @@ -3416,6 +3424,8 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt
// show. Keyboard settings write height/raise prefs while that cached
// view is still the old footprint — rebuild when they diverge so the
// next focus actually shows what the settings preview promised.
// A channel may have been turned off in settings since the last show.
if (isBackendReady()) refreshAsrRoute()
val key = keyboardFootprintKey()
if (appliedKeyboardFootprintKey != key) {
appliedKeyboardFootprintKey = key
Expand Down Expand Up @@ -3613,6 +3623,8 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt
if (!ready) {
OpenLessProcessRestartStats(this, "heartbeat").recordStart()
ensureBackendReady()
} else {
refreshAsrRoute()
}
}

Expand All @@ -3637,13 +3649,51 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt
recording -> LINK_COLOR_RECORDING
processing -> LINK_COLOR_PROCESSING
!backendLinkHealthy -> LINK_COLOR_ISSUE
asrRoute == "localRaw" || (asrRoute == "local" && !networkAvailable) -> LINK_COLOR_READY_LOCAL_RAW
asrRoute == "local" -> LINK_COLOR_READY_LOCAL
asrRoute == "unavailable" -> LINK_COLOR_ISSUE
else -> LINK_COLOR_READY
}
(backendLinkIndicator?.background as? GradientDrawable)?.setColor(color)
(backendLinkIndicator?.background as? GradientDrawable)?.apply {
setColor(color)
// The light-white dot would vanish on the light keyboard without an edge.
if (color == LINK_COLOR_READY_LOCAL_RAW) setStroke(dp(1), Color.rgb(148, 163, 184)) else setStroke(0, Color.TRANSPARENT)
}
val ready = color == LINK_COLOR_READY || color == LINK_COLOR_READY_LOCAL || color == LINK_COLOR_READY_LOCAL_RAW
backendLinkPulseAnimator?.duration =
if (color == LINK_COLOR_READY) BACKEND_LINK_READY_PULSE_DURATION_MS else BACKEND_LINK_PULSE_DURATION_MS
if (ready) BACKEND_LINK_READY_PULSE_DURATION_MS else BACKEND_LINK_PULSE_DURATION_MS
}

/**
* Asks the backend whether the next dictation runs locally and repaints
* the dot. The call reads the credential store, so it runs on its own
* thread; overlapping requests (heartbeat plus a keyboard show) collapse
* into one.
*/
private fun refreshAsrRoute() {
if (!asrRouteRefreshing.compareAndSet(false, true)) return
Thread {
val route = runCatching { OpenLessNative.nativeAsrRoute() }.getOrNull() ?: "unavailable"
val online = hasValidatedNetwork()
backendHeartbeatHandler.post {
asrRouteRefreshing.set(false)
if (route != asrRoute || online != networkAvailable) {
asrRoute = route
networkAvailable = online
updateBackendLinkIndicator()
}
}
}.apply { name = "openless-asr-route" }.start()
}

/** Whether the active network reaches the internet — polish needs it; local recognition doesn't. */
private fun hasValidatedNetwork(): Boolean = runCatching {
val connectivity = getSystemService(android.net.ConnectivityManager::class.java) ?: return@runCatching true
val capabilities = connectivity.getNetworkCapabilities(connectivity.activeNetwork) ?: return@runCatching false
capabilities.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}.getOrDefault(true)

// Guards scheduleBackendReadyRecheck() so a second mic tap while a
// recheck loop is already running doesn't stack a duplicate one.
private var awaitingBackendReadyRecheck = false
Expand Down Expand Up @@ -5915,6 +5965,10 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt
// (recording/processing), plus a light-green "ready" and an amber
// "link issue" that overlay doesn't have.
private val LINK_COLOR_READY = Color.rgb(134, 239, 172)
// Ready, and the next dictation runs on the local model (asrRoute).
private val LINK_COLOR_READY_LOCAL = Color.rgb(21, 128, 61)
// Ready, local model and raw output: no usable polish channel or no network.
private val LINK_COLOR_READY_LOCAL_RAW = Color.rgb(241, 245, 249)
private val LINK_COLOR_RECORDING = Color.rgb(244, 63, 94)
// Recording with rawModeArmed set (mic swipe-up: skip LLM polish on
// stop) — distinct orange so glancing at the dot tells the two
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,21 @@ class OpenLessKeyboardSettingsActivity : Activity() {
},
)

// "语音识别来源": how many dictations in history the cloud and the local
// model each recognized (with the local standby racing the cloud
// either can win), the latest one's kind highlighted.
content.addView(sectionLabel(ui("语音识别来源", "Speech recognition source")))
val asrUsageText = TextView(this).apply {
text = ui("读取中…", "Loading…")
textSize = 14f
setTextColor(tone(Color.rgb(220, 220, 220), Color.rgb(50, 50, 55)))
}
content.addView(asrUsageText)
Thread {
val usage = runCatching { OpenLessNative.nativeAsrUsage() }.getOrNull()
runOnUiThread { asrUsageText.text = asrUsageSummary(usage) }
}.apply { name = "openless-asr-usage" }.start()

// 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
Expand Down Expand Up @@ -744,6 +759,25 @@ class OpenLessKeyboardSettingsActivity : Activity() {
}
}

/** "云 N · 本 M" from nativeAsrUsage()'s "<local>,<cloud>,<latest>", the latest kind bold and colored. */
private fun asrUsageSummary(usage: String?): CharSequence {
val parts = usage?.split(',') ?: return ui("后台服务未就绪", "Backend not ready")
val local = parts.getOrNull(0)?.toIntOrNull() ?: 0
val cloud = parts.getOrNull(1)?.toIntOrNull() ?: 0
if (local + cloud == 0) return ui("暂无听写记录", "No dictations yet")
val cloudText = ui("云 $cloud", "Cloud $cloud")
val localText = ui("本 $local", "Local $local")
val text = android.text.SpannableString("$cloudText · $localText")
val (start, end, color) = when (parts.getOrNull(2)) {
"cloud" -> Triple(0, cloudText.length, tone(Color.rgb(96, 165, 250), Color.rgb(37, 99, 235)))
"local" -> Triple(text.length - localText.length, text.length, tone(Color.rgb(74, 222, 128), Color.rgb(21, 128, 61)))
else -> return text
}
text.setSpan(android.text.style.ForegroundColorSpan(color), start, end, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
text.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), start, end, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
return text
}

private fun sectionLabel(text: String): View = TextView(this).apply {
this.text = text
textSize = 12f
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/android/kotlin/OpenLessNative.kt
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ object OpenLessNative {
@JvmStatic external fun nativeHasRegisteredActivityContext(): Boolean

@JvmStatic external fun nativeBackendSnapshot(): String

/** "local", "cloud" or "unavailable" — which recognizer the next dictation uses. Blocks on a credential read: call off the main thread. */
@JvmStatic external fun nativeAsrRoute(): String?
/** "<local>,<cloud>,<latest>" dictation counts from history; latest is "local", "cloud" or empty. Reads history: call off the main thread. */
@JvmStatic external fun nativeAsrUsage(): String?
@JvmStatic external fun nativeImeCommand(json: String): String
@JvmStatic external fun nativeCloudPinyin(pinyin: String): String
@JvmStatic external fun nativePublicDictionary(base: String, action: String): String
Expand Down
Loading
Loading