Skip to content

feat(android): add IME, software keyboard, and stroke input - #1067

Open
DepengWang wants to merge 34 commits into
Open-Less:betafrom
DepengWang:feature/android-stroke-index-association
Open

feat(android): add IME, software keyboard, and stroke input#1067
DepengWang wants to merge 34 commits into
Open-Less:betafrom
DepengWang:feature/android-stroke-index-association

Conversation

@DepengWang

Copy link
Copy Markdown

Summary

Adds a native Android IME input path to OpenLess, including:

  • Voice dictation directly through the Android IME input connection
  • An English software keyboard for temporary letter and symbol input
  • A stroke-based Chinese input mode with offline dictionaries, candidate ranking, phrase association, simplified/traditional output, numeric panel, swipe-up digits, personalization, and long-press repeat
  • Android background runtime warmup and IME lifecycle handling
  • A dark, touch-responsive keyboard UI with haptic feedback and custom-drawn stroke/action glyphs

Motivation

This gives Android users a complete system keyboard workflow instead of relying only on the floating overlay or clipboard-style text insertion. Voice remains the primary input method, while English and stroke modes provide practical fallback input directly inside the same IME.

Verification

  • Synced Android scaffolding and manifest
  • Built successfully with app:assembleArm64Debug
  • Installed and exercised on a physical Android device
  • Verified voice, English software keyboard, stroke candidates, numeric-panel return, swipe digits, and direct InputConnection output

Real-device crash-loop root cause: OpenLessBackendWarmupActivity's
window Surface could be destroyed (not just backgrounded) via the
default back-button behavior, racing HWUI's worker-pool teardown and
aborting the whole process with a native destroyed-mutex signal. The
in-memory retry throttle then reset on every crash-restart, so the IME
immediately relaunched the same Activity and crashed again forever.

- Persist the backend-warmup retry timestamp in SharedPreferences
  (30s cooldown) so it survives a process crash/restart.
- Remove the "relaunch the focused app's launcher Activity" fallback,
  which could land the user on the wrong screen entirely.
- Route settings through a single tracked warmup-Activity instance
  instead of ever starting a second, untracked MainActivity host.
- Override onBackPressed() to always background (never finish) this
  Activity, since finishing it is what destroys the window Surface.

Verified on-device: 3x cold-start + open-settings + back-button stress
cycles with zero crashes across the crash log.
- Replace the Stroke/EN toggle text labels with fixed-size PNG icons
  (drawable-nodpi/toggle_stroke.png, toggle_en.png) so they no longer
  change size with the UI language, and enlarge them for legibility.
- Fix header/toggle position drift: the four keyboard panels (voice,
  English, stroke, stroke-number) each used different root padding for
  their own body rows, which shifted the logo/toggle left-right and
  up-down when switching modes. Compensate with per-panel margins so
  all four land at the same canonical 16dp/8dp inset.
- Enlarge stroke-panel labels (通配/分词/符号/繁/清除/123) and move the
  small per-key numbers down to match the "0" key's native TextView
  top-gravity position instead of sitting almost flush with the edge.
- Remove the microphone key's "CHERRY◆" watermark text.
- Long-pressing the stroke keyboard's mic/"0" key now also starts
  dictation immediately, instead of only switching to voice mode.
…, 3-state shift

Stroke keyboard:
- Implement real word segmentation (分词): pressing it marks the current
  character as a segment in memory without committing it, shows a
  leading "commit whole word" candidate, and folds any pending segments
  into the next candidate tap so the last character never needs an
  explicit segment press.
- Number/symbol panel: backspace/enter/voice/return/symbols labels are
  now always Chinese regardless of UI language (also fixes enter having
  shown "←" instead of "回车"); "符号" now switches the digit grid to a
  paged symbol set (3 pages x 12, default page is common punctuation)
  with ▲/▼ paging, and the toggle relabels to "数字" while in symbol mode.
  Added a "0" key (phone dial-pad style, under "8").
- The main stroke panel's "符号" key now jumps straight into the number
  panel's symbol mode instead of just typing "#".
- Left punctuation rail is swipeable: 5 groups of 5 symbols, one swipe
  advances exactly one group.
- Redrew the 丿/丶/乙 (keys 3/4/5) stroke glyphs by pixel-tracing a
  reference screenshot instead of eyeballing bezier curves.

Cross-panel swipe gestures:
- Left/right swipe on any panel's root steps InputMode toward
  Voice/English (Voice-Stroke-English order, clamped at both ends),
  matching the toggle switch.
- Fixed the vertical punctuation-rail swipe fighting Android's system
  gesture navigation and visibly flickering the IME closed/open: defer
  the view-rebuild until after the touch gesture unwinds (post{} instead
  of calling inline mid-dispatch), add systemGestureExclusionRects, and
  requestDisallowInterceptTouchEvent once a drag is claimed.

English keyboard:
- Shift is now 3-state (lowercase/shift-once/caps-lock) with 3 distinct
  icons (hollow arrow / solid arrow / solid arrow + underline), cycled
  by tapping. Letter key labels now show the case that will actually be
  typed, including reverting to lowercase after a one-shot capital.

Voice panel:
- Removed the keyboard-switch ("⌨") button. Rearranged @ / return /
  backspace to @-return-backspace, all 48dp tall, return centered.
  Widened @ and backspace 1.5x and restyled all three as a flat pill
  (matching the mic capsule's own fill, no elevation) instead of the
  stroke panel's raised-keycap look. Backspace supports long-press
  repeat.
- Recording waveform lengthened (17 bars) with a genuine left-flowing
  traveling wave instead of a static shape with per-bar shimmer.
  Thinking/processing indicator's orbit ring now breathes (radius
  grows and shrinks) on top of its existing rotation, uniformly across
  all dots rather than sizing each dot off its own angle.
DepengWang and others added 4 commits September 13, 2026 08:38
…service

Investigating a reproducible "host app visibly refreshes when you tap its
text field" report (seen in Meituan, but not app-specific) showed the
cause is structural, not a crash: whenever the Tauri/Rust backend needs a
cold start, ensureBackendReady() launches OpenLessBackendWarmupActivity,
which steals the foreground for a moment. Logs show the host app's window
losing focus and coming back with HIDE_WINDOW_GAINED_FOCUS_WITHOUT_EDITOR
— the host app sees its editor focus yanked and re-initializes its screen.

This does not fix that architecture (the backend still needs an Activity
to host Tauri's event loop), but widens the window for the warmup to
happen before the user is looking at someone else's text field:

- Move the warmup check out of OpenLessImeService into a shared
  OpenLessBackendWarmupActivity.ensureBackendReady(Context), so it can be
  called from any Context rather than only from the IME reacting to a
  focused editor.
- Call it from OpenLessRuntimeService.onStartCommand() as well. That
  service is START_STICKY, so the system can restart it on its own (e.g.
  after reclaiming it under memory pressure) with no IME interaction
  involved — those restarts now get a chance to warm the backend early.

The existing 30s persisted cooldown is shared by all callers, so the
extra call site cannot increase how often the warmup Activity launches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ngBxFcjxx36GkNKt7UWHV
(cherry picked from commit aac9bef on
origin/main, adapted for this branch)

Pulls in the write-gate race-condition fix from upstream main: a local
optimistic settings write could be overwritten by an older
`prefs:changed` broadcast that arrives after the write started but
before it resolves, making a toggle briefly "bounce back" to its old
value. Fixes this by tracking per-field pending local changes and
correlating incoming broadcasts against writes this webview itself
issued (via an upgraded PreferencesWriteGate with value comparison and
recent-write history, replacing the old counter-only version).

Adapted for divergence from upstream main (this branch already
contains all of beta, which has evolved well past main in this area):

- android::commands::settings::set_settings (the #[cfg(mobile)] variant)
  now returns Result<UserPreferences, String> instead of Result<(),
  String>, matching desktop's set_settings and what ipc/settings.ts's
  setSettings() already expects. This also fixes a latent Android-only
  bug: the frontend's setSettings() has been typed to return
  Promise<UserPreferences> since it already matches desktop, but on
  Android it silently got `undefined` back at runtime. Kept this
  branch's existing coord.backend() accessor style rather than
  upstream's newer coord.prefs()/coord.style_packs() (this branch has
  never adopted that accessor split). Did not add upstream's inline
  app.emit("prefs:changed", ...) calls or an app: AppHandle parameter:
  this branch already broadcasts prefs changes through
  BackendEventKind::PreferencesChanged -> tauri_events.rs, which
  mobile_runtime.rs wires up for Android too, so an inline emit would
  just double-broadcast.
- HotkeySettingsContext.tsx: ported upstream's write-gate integration
  (applyIncomingPrefs, pendingLocalChangesRef, prefsChangeVersionRef,
  waitForPersistence) but dropped its locale-output-preference sync
  effect — that feature (outputPrefsForLocale/SupportedLocale) no
  longer exists anywhere in this branch's i18n module, so re-adding it
  would resurrect removed functionality and fail to compile. Re-applied
  this branch's own applyStackedLayoutFromPrefs/applyConservativeLayout
  calls (upstream doesn't have these) in the three spots this branch
  already had them.
- shared.tsx: merged cleanly on its own; manually added upstream's new
  SectionDesc no-op placeholder component that was missing here.
- ipc/settings.ts, ExperimentalSectionTitle.test.ts,
  i18n/experimentalTitles.test.ts, i18n/{en,ja,ko,zh-CN,zh-TW}.ts,
  MultimodalPipelineSection.tsx, Casks/openless.rb: kept this branch's
  existing content — either already a superset of upstream's fix, or
  differed only in formatting with identical text/logic.
- LocalModelSection.tsx: kept deleted (this branch already replaced it
  via beta's "2.0 model settings page" rework; upstream's change here
  only applied ExperimentalSectionTitle to the old file).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ngBxFcjxx36GkNKt7UWHV
…ection-rule bridge

- Add global swipe-down-to-dismiss (120dp) and raise the panel-switch
  swipe threshold to 100dp, with a slide transition between panels.
- Rework the stroke keyboard's encode+candidate strip to a fixed
  24dp/36dp layout with a flat background and hairline divider; the
  selected/first candidate is now bold light-blue (reusing the encode
  text's own color) instead of red, with a "show more" overlay that
  doesn't resize the panel.
- Tighten the 3x4 stroke grid's key spacing to ~2dp and align it
  exactly with the red action-key column and punctuation rail.
- Add a full-screen native keyboard-settings activity (long-press the
  logo) with a vibration intensity/duration framework, replacing the
  old popup overlay.
- Fix interface-language sync: OpenLessApplication watched MainActivity
  by exact class, which never matched OpenLessBackendWarmupActivity
  where settings actually run, so the mirrored locale pref went stale.
- Add clipboard history persistence and restyle clipboard quick actions
  to match the stroke panel's button style.
- Wire a correction-rule JNI bridge so hand-edited dictation results
  feed back into the shared correction dictionary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ngBxFcjxx36GkNKt7UWHV
…h check

- Learn a per-code personal preference for the stroke keyboard's direct
  candidate list: picking a non-top candidate boosts it for that exact
  stroke code from then on, ranked ahead of (not blended with) the
  static corpus frequency. Shares StrokeUserFrequency's store with
  phrase association, now global instead of per-target-app, with the
  entry cap raised 512 -> 3000 to fit both.
- Add a full light theme for every keyboard panel, sourced from the
  OpenLess app's own Settings > Appearance choice (mirrored via the
  existing WebView poll into openless_ime_ui, not the raw OS dark-mode
  setting), with a same-session OS-setting fallback before that's ever
  been resolved once.
- Fix swipe-down-to-dismiss stealing the stroke panel's punctuation
  rail gesture: excludes the rail's screen region by touch-down
  position instead of relying on child-vs-parent intercept timing.
- Add a mic silence watch to the voice panel: no meaningful input level
  within 3s of a recording actually starting shows a bold red warning
  below the button (tap to restart the app) that persists until either
  a fresh recording attempt or real audio is detected. Fixes the timer
  never actually resetting, since toggleDictation() flips `recording`
  true before onCapsuleStateChanged's own reset check could ever fire.
- Keyboard settings: raise vibration duration's cap 100ms -> 500ms,
  fire a test pulse on slider release instead of a separate button, and
  show personal-preference usage against its cap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ngBxFcjxx36GkNKt7UWHV
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants