diff --git a/app/src/main/java/com/android/inputmethod/keyboard/ProximityInfo.java b/app/src/main/java/com/android/inputmethod/keyboard/ProximityInfo.java index 9015f3e6d..f2b59a187 100644 --- a/app/src/main/java/com/android/inputmethod/keyboard/ProximityInfo.java +++ b/app/src/main/java/com/android/inputmethod/keyboard/ProximityInfo.java @@ -6,6 +6,7 @@ package com.android.inputmethod.keyboard; +import android.content.Context; import android.graphics.Rect; import helium314.keyboard.latin.utils.Log; @@ -14,6 +15,10 @@ import helium314.keyboard.keyboard.Key; import helium314.keyboard.keyboard.internal.TouchPositionCorrection; import helium314.keyboard.latin.common.Constants; +import helium314.keyboard.latin.database.TouchModelDao; +import helium314.keyboard.latin.database.TouchModelManager; +import helium314.keyboard.latin.settings.Settings; +import helium314.keyboard.latin.settings.SettingsValues; import helium314.keyboard.latin.utils.JniUtils; import java.util.ArrayList; @@ -48,12 +53,17 @@ public class ProximityInfo { private final List mSortedKeys; @NonNull private final List[] mGridNeighbors; + // Adaptive typing: the keyboard element this proximity info is for (e.g. alphabet vs symbols). + // Keys the learned touch model by (key, this element, orientation) so layouts stay separate. + private final int mLayoutElementId; @SuppressWarnings("unchecked") public ProximityInfo(final int gridWidth, final int gridHeight, final int minWidth, final int height, final int mostCommonKeyWidth, final int mostCommonKeyHeight, @NonNull final List sortedKeys, - @NonNull final TouchPositionCorrection touchPositionCorrection) { + @NonNull final TouchPositionCorrection touchPositionCorrection, + final int layoutElementId) { + mLayoutElementId = layoutElementId; mGridWidth = gridWidth; mGridHeight = gridHeight; mGridSize = mGridWidth * mGridHeight; @@ -165,14 +175,34 @@ private long createNativeProximityInfo(@NonNull final TouchPositionCorrection to infoIndex++; } - if (touchPositionCorrection.isValid()) { + // Adaptive typing (opt-in): when enabled, shift each key's sweet-spot center by the + // learned per-user landing offset (capped) so the native recognizer matches swipes — and + // tap-correction candidates — against keys where the user's hand actually goes. This + // path runs even when the layout ships no static touch-position-correction data, so we + // still generate sweet spots in that case. The shift is computed in Java and crosses the + // existing JNI; no native change. See docs/ADAPTIVE_TYPING.md. + final boolean tpcValid = touchPositionCorrection.isValid(); + final SettingsValues sv = Settings.getValues(); + final boolean adaptiveOn = sv != null && sv.mAdaptiveKeyGeometry; + final int adaptiveStrength = sv != null ? sv.mAdaptiveKeyGeometryStrength : 0; + TouchModelDao adaptiveDao = null; + int adaptiveOrientation = 0; + if (adaptiveOn && adaptiveStrength > 0) { + final Context ctx = Settings.getCurrentContext(); + if (ctx != null) { + adaptiveDao = TouchModelDao.getInstance(ctx); + adaptiveOrientation = ctx.getResources().getConfiguration().orientation; + } + } + final boolean applyAdaptive = adaptiveDao != null; + if (tpcValid || applyAdaptive) { if (DEBUG) { - Log.d(TAG, "touchPositionCorrection: ON"); + Log.d(TAG, "sweet spots: ON (tpc=" + tpcValid + " adaptive=" + applyAdaptive + ")"); } sweetSpotCenterXs = new float[keyCount]; sweetSpotCenterYs = new float[keyCount]; sweetSpotRadii = new float[keyCount]; - final int rows = touchPositionCorrection.getRows(); + final int rows = tpcValid ? touchPositionCorrection.getRows() : 0; final float defaultRadius = DEFAULT_TOUCH_POSITION_CORRECTION_RADIUS * (float)Math.hypot(mMostCommonKeyWidth, mMostCommonKeyHeight); for (int infoIndex = 0, keyIndex = 0; keyIndex < sortedKeys.size(); keyIndex++) { @@ -186,7 +216,7 @@ private long createNativeProximityInfo(@NonNull final TouchPositionCorrection to sweetSpotCenterYs[infoIndex] = hitBox.exactCenterY(); sweetSpotRadii[infoIndex] = defaultRadius; final int row = hitBox.top / mMostCommonKeyHeight; - if (row < rows) { + if (tpcValid && row < rows) { final int hitBoxWidth = hitBox.width(); final int hitBoxHeight = hitBox.height(); final float hitBoxDiagonal = (float)Math.hypot(hitBoxWidth, hitBoxHeight); @@ -197,11 +227,20 @@ private long createNativeProximityInfo(@NonNull final TouchPositionCorrection to sweetSpotRadii[infoIndex] = touchPositionCorrection.getRadius(row) * hitBoxDiagonal; } + if (applyAdaptive) { + final TouchModelDao.Stat stat = adaptiveDao.get(key.getCode(), + Integer.toString(mLayoutElementId), adaptiveOrientation); + final float[] off = TouchModelManager.adjustedOffset( + stat, key.getWidth(), key.getHeight(), adaptiveStrength, + System.currentTimeMillis(), sv.adaptiveForgetHalfLifeMs()); + sweetSpotCenterXs[infoIndex] += off[0]; + sweetSpotCenterYs[infoIndex] += off[1]; + } if (DEBUG) { Log.d(TAG, String.format(Locale.US, " [%2d] row=%d x/y/r=%7.2f/%7.2f/%5.2f %s code=%s", infoIndex, row, sweetSpotCenterXs[infoIndex], sweetSpotCenterYs[infoIndex], - sweetSpotRadii[infoIndex], (row < rows ? "correct" : "default"), + sweetSpotRadii[infoIndex], (tpcValid && row < rows ? "correct" : "default"), Constants.printableCode(key.getCode()))); } infoIndex++; @@ -209,7 +248,7 @@ private long createNativeProximityInfo(@NonNull final TouchPositionCorrection to } else { sweetSpotCenterXs = sweetSpotCenterYs = sweetSpotRadii = null; if (DEBUG) { - Log.d(TAG, "touchPositionCorrection: OFF"); + Log.d(TAG, "sweet spots: OFF"); } } diff --git a/app/src/main/java/helium314/keyboard/keyboard/AdaptiveKeyContext.java b/app/src/main/java/helium314/keyboard/keyboard/AdaptiveKeyContext.java new file mode 100644 index 000000000..f93a08698 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/keyboard/AdaptiveKeyContext.java @@ -0,0 +1,136 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + */ +package helium314.keyboard.keyboard; + +import helium314.keyboard.latin.SuggestedWords; + +import java.util.HashMap; +import java.util.Map; + +/** + * Holds a transient "likely next key" prior derived from the current suggestion strip, used to + * gently enlarge the touch target of likely next keys (adaptive typing, see + * docs/ADAPTIVE_TYPING.md). + * + *

It is rebuilt between keystrokes — whenever the suggestions change — on the UI thread, and + * read per tap in {@link KeyDetector}. Reads are lock-free via {@code volatile} parallel arrays, + * which are tiny (at most a handful of distinct next-characters), so the tap hot path stays fast + * even for very fast typists. Because suggestions are computed asynchronously, the prior may lag + * the latest keystroke by one tap under very fast typing; that is harmless, as the prior is only + * a soft, capped bias. + * + *

Suggestions are weighted EQUALLY (averaged) rather than by score, so the result is not skewed + * toward the single top suggestion. + */ +public final class AdaptiveKeyContext { + /** How many suggestions to average over. */ + private static final int TOP_N = 5; + + private static volatile int[] sCodes; + private static volatile float[] sWeights; + + /** + * Optional observer notified whenever the prior changes, on the same (UI) thread that mutates + * it. Used only by the debug overlay (see AdaptiveTargetsDrawingPreview) to repaint the live + * keyboard as the prior shifts between keystrokes; null in normal operation. Volatile so the + * keyboard view can register/clear it from its own lifecycle without extra locking. + */ + private static volatile Runnable sChangeListener; + + private AdaptiveKeyContext() {} + + /** Register (or clear, with {@code null}) the debug repaint observer. */ + public static void setChangeListener(final Runnable listener) { + sChangeListener = listener; + } + + private static void fireChanged() { + final Runnable l = sChangeListener; + if (l != null) l.run(); + } + + /** + * Rebuild the prior from the top suggestions. + * + * @param words the current suggestion strip contents. + * @param position index of the NEXT character within each suggestion — the current + * composing-word length while a word is being built, or 0 for a new word + * (using next-word predictions, whose first letter is the likely next key). + */ + public static void update(final SuggestedWords words, final int position) { + if (words == null || words.isEmpty() || position < 0) { + clear(); + return; + } + final int n = Math.min(TOP_N, words.size()); + final HashMap tally = new HashMap<>(); + int considered = 0; + for (int i = 0; i < n; i++) { + final String w = words.getWord(i); + if (w == null || position >= w.length()) continue; // typed word itself / too short + final int cp = Character.toLowerCase(w.charAt(position)); + if (!Character.isLetter(cp)) continue; + tally.merge(cp, 1, Integer::sum); + considered++; + } + if (considered == 0) { + clear(); + return; + } + final int[] codes = new int[tally.size()]; + final float[] weights = new float[tally.size()]; + int j = 0; + for (final Map.Entry e : tally.entrySet()) { + codes[j] = e.getKey(); + weights[j] = (float) e.getValue() / considered; // 0..1, equal-weight average + j++; + } + sCodes = codes; + sWeights = weights; + fireChanged(); + } + + public static void clear() { + // Already clear: do NOT fire. This is the default path — with the context prior off, + // InputLogic.setSuggestedWords calls clear() on every suggestion update, and firing here + // would invalidate the overlay view on every keystroke for users who never enabled the + // feature. Only fire on an actual transition from non-empty to empty. + if (sCodes == null && sWeights == null) return; + sCodes = null; + sWeights = null; + fireChanged(); + } + + /** Prior weight in [0, 1] for the given key code (0 if none / no prior). Case-insensitive: + * the prior stores lowercase next-characters, but a shifted keyboard reports uppercase key + * codes, so we fold to lowercase to match (otherwise the bias/overlay miss capital letters). */ + public static float weight(final int code) { + final int[] c = sCodes; + final float[] w = sWeights; + if (c == null || w == null) return 0f; + final int lower = Character.toLowerCase(code); + for (int i = 0; i < c.length && i < w.length; i++) { + if (c[i] == lower) return w[i]; + } + return 0f; + } + + public static boolean hasPrior() { + return sCodes != null; + } + + /** Human-readable snapshot of the current prior, e.g. {@code [e=0.60,o=0.40]}, for debug logs. */ + public static String debugString() { + final int[] c = sCodes; + final float[] w = sWeights; + if (c == null || w == null) return "(none)"; + final StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < c.length && i < w.length; i++) { + if (i > 0) sb.append(','); + sb.append((char) c[i]).append('=') + .append(String.format(java.util.Locale.US, "%.2f", w[i])); + } + return sb.append(']').toString(); + } +} diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyDetector.java b/app/src/main/java/helium314/keyboard/keyboard/KeyDetector.java index 400511cda..7045ccd81 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyDetector.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyDetector.java @@ -6,10 +6,26 @@ package helium314.keyboard.keyboard; +import android.content.Context; +import android.graphics.Rect; + +import helium314.keyboard.latin.database.TouchModelDao; +import helium314.keyboard.latin.database.TouchModelManager; +import helium314.keyboard.latin.settings.Settings; +import helium314.keyboard.latin.settings.SettingsValues; +import helium314.keyboard.latin.utils.Log; + /** * This class handles key detection. */ public class KeyDetector { + // Adaptive typing: the context prior may enlarge a likely key's effective target by at most + // this fraction of the key (deliberately a bit less than the learned-geometry cap, so the + // prior nudges rather than dominates). See docs/ADAPTIVE_TYPING.md. + private static final float PRIOR_MAX_FRACTION = 0.18f; + // A neighbor is only allowed to win a tap if the touch is within this fraction of its hitbox. + private static final float CONSIDER_MARGIN_FRACTION = 0.40f; + private final int mKeyHysteresisDistanceSquared; private final int mKeyHysteresisDistanceForSlidingModifierSquared; @@ -101,6 +117,111 @@ public Key detectHitKey(final int x, final int y) { primaryKey = key; } } + // Adaptive typing (opt-in): for a plain tap (not a gesture/swipe), let the learned + // per-key landing offset and the current next-key prior gently bias which key wins — + // bounded so only genuinely ambiguous, near-boundary taps can flip. + if (primaryKey != null && !PointerTracker.isInGestureOrKeySwipe()) { + final Key biased = applyAdaptiveBias(touchX, touchY, primaryKey); + if (biased != null) return biased; + } return primaryKey; } + + /** Returns a key that should win this tap instead of {@code geo} due to learned/prior bias, + * or {@code null} to keep the plain geometric result. */ + private Key applyAdaptiveBias(final int touchX, final int touchY, final Key geo) { + final SettingsValues sv = Settings.getValues(); + if (sv == null || sv.mAdaptiveKeyGeometryStrength <= 0) return null; + // The two halves are independently toggleable: learned per-key offset, and the + // context prior. Either alone is enough to bias a tap; both share the strength slider. + final boolean learn = sv.mAdaptiveKeyGeometry; + final boolean usePrior = sv.mAdaptiveContextPrior; + if (!learn && !usePrior) return null; + final Context ctx = Settings.getCurrentContext(); + if (ctx == null || mKeyboard == null) return null; + final TouchModelDao dao = learn ? TouchModelDao.getInstance(ctx) : null; + final boolean hasPrior = usePrior && AdaptiveKeyContext.hasPrior(); + if (dao == null && !hasPrior) return null; // nothing to bias with + final String layout = Integer.toString(mKeyboard.mId.mElementId); + final int orientation = ctx.getResources().getConfiguration().orientation; + final int strength = sv.mAdaptiveKeyGeometryStrength; + final long now = System.currentTimeMillis(); + final long halfLifeMs = sv.adaptiveForgetHalfLifeMs(); + + // Guard the flip: bias must never override a press the user made clearly inside a key. A + // neighbor may win ONLY if the touch also lands inside ITS hitbox, or it is geometrically + // no farther (true, unshifted center) than the pressed key. Combined with the per-neighbor + // checks below, this caps the learned-offset + prior combination so an off-center-but- + // unambiguous tap can't be stolen — only a genuinely near-boundary tap can flip. + final float geoGeomSq = geomCenterDistSq(geo, touchX, touchY); + final TouchModelDao.Stat geoStat = (dao == null) ? null : dao.get(geo.getCode(), layout, orientation); + + Key best = geo; + float bestScore = adjustedDistance(geo, touchX, touchY, geoStat, strength, usePrior, now, halfLifeMs); + for (final Key k : mKeyboard.getNearestKeys(touchX, touchY)) { + if (k == geo) continue; + final int code = k.getCode(); + if (code <= 0 || !Character.isLetter(code)) continue; + // Only a genuinely-favored neighbor (a confident learned offset and/or a next-key + // prior) may steal a near-boundary tap; otherwise leave the geometric result alone. + final float prior = usePrior ? AdaptiveKeyContext.weight(code) : 0f; + final TouchModelDao.Stat st = (dao == null) ? null : dao.get(code, layout, orientation); + final boolean hasLearned = st != null && st.getCount() >= TouchModelDao.MIN_CONFIDENT_SAMPLES; + if (prior <= 0f && !hasLearned) continue; + // Bound: the touch must be within a margin of the neighbor's hitbox... + final float margin = CONSIDER_MARGIN_FRACTION * k.getWidth(); + if (k.squaredDistanceToEdge(touchX, touchY) > margin * margin) continue; + // ...and (the flip guard) the neighbor must contain the touch, or be no farther than geo. + if (!k.isOnKey(touchX, touchY) && geomCenterDistSq(k, touchX, touchY) > geoGeomSq) continue; + final float s = adjustedDistance(k, touchX, touchY, st, strength, usePrior, now, halfLifeMs); + if (s < bestScore) { + bestScore = s; + best = k; + } + } + if (sv.mAdaptiveDebugOverlay && hasPrior) { + Log.d("AdaptivePrior", "tap geo='" + (char) geo.getCode() + + "' priorOnGeo=" + AdaptiveKeyContext.weight(geo.getCode()) + + " chosen='" + (char) best.getCode() + "'" + + (best == geo ? "" : " (FLIPPED by bias)") + + " prior=" + AdaptiveKeyContext.debugString()); + } + return best == geo ? null : best; + } + + /** Distance from the touch to the key's effective center (center shifted by the learned + * landing offset {@code st}, when present) minus the capped next-key prior boost (when + * enabled). Smaller wins. Takes the pre-fetched {@code st} so a tap does ONE DAO lookup per + * key rather than two. */ + private float adjustedDistance(final Key k, final int touchX, final int touchY, + final TouchModelDao.Stat st, final int strength, final boolean usePrior, + final long now, final long halfLifeMs) { + final Rect hb = k.getHitBox(); + float cx = hb.exactCenterX(); + float cy = hb.exactCenterY(); + if (st != null) { + // Cap the shift against the HIT BOX — the same basis the offset was recorded against + // and the center we shift from — so the 0.25-key safety bound is geometrically + // consistent (key.getWidth() excludes the gap and would misstate the bound). The + // now/halfLifeMs apply the recency "forget window" so stale learning fades out. + final float[] off = TouchModelManager.adjustedOffset(st, hb.width(), hb.height(), strength, now, halfLifeMs); + cx += off[0]; + cy += off[1]; + } + final float dx = touchX - cx; + final float dy = touchY - cy; + final float dist = (float) Math.sqrt(dx * dx + dy * dy); + final float boost = usePrior + ? AdaptiveKeyContext.weight(k.getCode()) * PRIOR_MAX_FRACTION * k.getWidth() * (strength / 100f) + : 0f; + return dist - boost; + } + + /** Squared distance from the touch to the key's true (unshifted) hit-box center. */ + private static float geomCenterDistSq(final Key k, final int touchX, final int touchY) { + final Rect hb = k.getHitBox(); + final float dx = touchX - hb.exactCenterX(); + final float dy = touchY - hb.exactCenterY(); + return dx * dx + dy * dy; + } } diff --git a/app/src/main/java/helium314/keyboard/keyboard/Keyboard.java b/app/src/main/java/helium314/keyboard/keyboard/Keyboard.java index c89423538..807c33828 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/Keyboard.java +++ b/app/src/main/java/helium314/keyboard/keyboard/Keyboard.java @@ -113,7 +113,7 @@ public Keyboard(@NonNull final KeyboardParams params) { mProximityInfo = new ProximityInfo(params.GRID_WIDTH, params.GRID_HEIGHT, mOccupiedWidth, mOccupiedHeight, mMostCommonKeyWidth, mMostCommonKeyHeight, - mSortedKeys, params.mTouchPositionCorrection); + mSortedKeys, params.mTouchPositionCorrection, mId.mElementId); mProximityCharsCorrectionEnabled = params.mProximityCharsCorrectionEnabled; } diff --git a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java index 96399c446..30816b4a7 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java @@ -37,6 +37,7 @@ import helium314.keyboard.compat.ConfigurationCompatKt; import helium314.keyboard.keyboard.internal.DrawingPreviewPlacerView; import helium314.keyboard.keyboard.internal.DrawingProxy; +import helium314.keyboard.keyboard.internal.AdaptiveTargetsDrawingPreview; import helium314.keyboard.keyboard.internal.GestureDebugPointsDrawingPreview; import helium314.keyboard.keyboard.internal.GestureFloatingTextDrawingPreview; import helium314.keyboard.keyboard.internal.GestureTrailsDrawingPreview; @@ -126,6 +127,9 @@ public final class MainKeyboardView extends KeyboardView implements DrawingProxy private final SlidingKeyInputDrawingPreview mSlidingKeyInputDrawingPreview; // Debug overlay for two-thumb point hinting (#2.1), toggled by PREF_GESTURE_DEBUG_DRAW_POINTS. private final GestureDebugPointsDrawingPreview mGestureDebugPointsDrawingPreview; + // Debug overlay visualizing adaptive typing (learned offsets + next-key prior), toggled by + // PREF_ADAPTIVE_DEBUG_OVERLAY. See docs/ADAPTIVE_TYPING.md. + private final AdaptiveTargetsDrawingPreview mAdaptiveTargetsDrawingPreview; // Key preview private final KeyPreviewDrawParams mKeyPreviewDrawParams; @@ -233,6 +237,13 @@ public MainKeyboardView(final Context context, final AttributeSet attrs, final i // Debug overlay last so it draws ON TOP of the gesture trail / floating preview. mGestureDebugPointsDrawingPreview = new GestureDebugPointsDrawingPreview(); mGestureDebugPointsDrawingPreview.setDrawingView(drawingPreviewPlacerView); + // Adaptive-typing visualization overlay. Always "enabled" at the preview layer; the actual + // drawing is gated on its live pref each frame, so it costs nothing when toggled off. It + // repaints between keystrokes via the AdaptiveKeyContext change listener registered below. + mAdaptiveTargetsDrawingPreview = new AdaptiveTargetsDrawingPreview(); + mAdaptiveTargetsDrawingPreview.setDrawingView(drawingPreviewPlacerView); + mAdaptiveTargetsDrawingPreview.setPreviewEnabled(true); + AdaptiveKeyContext.setChangeListener(mAdaptiveTargetsDrawingPreview::onAdaptiveContextChanged); mainKeyboardViewAttr.recycle(); mDrawingPreviewPlacerView = drawingPreviewPlacerView; @@ -394,6 +405,9 @@ public void setKeyboard(@NonNull final Keyboard keyboard) { keyboard, -getPaddingLeft(), -getPaddingTop() + getVerticalCorrection()); PointerTracker.setKeyDetector(mKeyDetector); mPopupKeysKeyboardCache.clear(); + // Keys render at getX()+paddingLeft / getY()+paddingTop; hand those to the adaptive overlay + // so its markers line up with the drawn keys. + mAdaptiveTargetsDrawingPreview.setKeyboard(keyboard, getPaddingLeft(), getPaddingTop()); mSpaceKey = keyboard.getKey(Constants.CODE_SPACE); final int keyHeight = keyboard.mMostCommonKeyHeight - keyboard.mVerticalGap; diff --git a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java index 427b88e6b..a701b6a07 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java +++ b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java @@ -162,6 +162,12 @@ public static int consumeGestureSeedCodepoint() { return seed; } + /** True while a gesture, key-swipe (space/delete), or shortcut-row swipe is in progress. + * Adaptive tap biasing must be suppressed in these states (it only applies to plain taps). */ + public static boolean isInGestureOrKeySwipe() { + return sInGesture || sInKeySwipe || sInShortcutRowSwipe; + } + private static TypingTimeRecorder sTypingTimeRecorder; // The position and time at which first down event occurred. @@ -1591,6 +1597,7 @@ eventTime, getActivePointerTrackerCount(), graceMs, this, sLastLetterTapTime = eventTime; sLastLetterTapCodepoint = code; pushTapDebugPoint(mKeyX, mKeyY, mPointerId, eventTime); + recordAdaptiveTouchSample(currentKey, mKeyX, mKeyY); } } if (isInSlidingKeyInput) { @@ -1598,6 +1605,33 @@ eventTime, getActivePointerTrackerCount(), graceMs, this, } } + // Adaptive typing (opt-in, see docs/ADAPTIVE_TYPING.md): fold this letter tap's landing + // offset (touch minus key center) into the learned per-(key, layout, orientation) model so + // the spatial model can later bias gesture sweet-spots toward where the user actually types. + // Content-free (geometry only); gated on the opt-in pref and incognito. The DAO write is async. + private void recordAdaptiveTouchSample(final Key key, final int x, final int y) { + if (key == null || x < 0 || y < 0 || mKeyboard == null) return; + final SettingsValues sv = Settings.getValues(); + if (sv == null || !sv.mAdaptiveKeyGeometry || sv.mIncognitoModeEnabled) return; + final android.content.Context context = Settings.getCurrentContext(); + if (context == null) return; + final helium314.keyboard.latin.database.TouchModelDao dao = + helium314.keyboard.latin.database.TouchModelDao.getInstance(context); + if (dao == null) return; + final android.graphics.Rect hitBox = key.getHitBox(); + // Measure the landing offset in the SAME coordinate space the hit box lives in: detection + // applies the key-detector correction (getTouchX/Y), so we must too, otherwise a non-zero + // keyboard-origin correction would skew every learned offset by a constant. The adaptive + // bias never moves these coordinates, so the model trains on the raw finger position (no + // learn-on-corrected-output feedback loop). + final float dx = mKeyDetector.getTouchX(x) - hitBox.exactCenterX(); + final float dy = mKeyDetector.getTouchY(y) - hitBox.exactCenterY(); + dao.record(key.getCode(), Integer.toString(mKeyboard.mId.mElementId), + context.getResources().getConfiguration().orientation, dx, dy, + hitBox.width(), hitBox.height(), System.currentTimeMillis(), + sv.adaptiveForgetHalfLifeMs()); + } + @Override public void cancelTrackingForAction() { if (isShowingPopupKeysPanel()) { diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/AdaptiveTargetsDrawingPreview.java b/app/src/main/java/helium314/keyboard/keyboard/internal/AdaptiveTargetsDrawingPreview.java new file mode 100644 index 000000000..22bcf4a6f --- /dev/null +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/AdaptiveTargetsDrawingPreview.java @@ -0,0 +1,195 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + */ + +package helium314.keyboard.keyboard.internal; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; + +import androidx.annotation.NonNull; + +import helium314.keyboard.keyboard.AdaptiveKeyContext; +import helium314.keyboard.keyboard.Key; +import helium314.keyboard.keyboard.Keyboard; +import helium314.keyboard.keyboard.PointerTracker; +import helium314.keyboard.latin.database.TouchModelDao; +import helium314.keyboard.latin.database.TouchModelManager; +import helium314.keyboard.latin.settings.Settings; +import helium314.keyboard.latin.settings.SettingsValues; + +/** + * Debug visualization for adaptive typing (opt-in via {@code PREF_ADAPTIVE_DEBUG_OVERLAY}). Drawn + * on top of the live keyboard, it makes the two halves of the feature visible as you type: + * + *

    + *
  • Learned key geometry — for each letter key with a confident learned landing offset, + * a faint ring marks the key's geometric centre, an arrow points to where the user's taps + * actually land on average, and a filled dot marks that learned target. This is the same + * offset {@link helium314.keyboard.keyboard.KeyDetector} biases taps toward.
  • + *
  • Next-key context prior — keys the current suggestions predict get a translucent + * halo whose radius grows with the prior weight, centred on the (possibly shifted) target. + * The halo appears/grows/shrinks between keystrokes, so the keyboard visibly "leans" toward + * the likely next key.
  • + *
+ * + *

The overlay is purely visual — it never changes detection. It reads the same live model + * ({@link TouchModelDao}), prior ({@link AdaptiveKeyContext}) and {@link SettingsValues} the engine + * uses, so what you see is what the engine does (the halo radius is exaggerated relative to the + * engine's sub-key boost so the effect is legible). Drawing is gated on the live pref each frame, + * so it costs nothing when the toggle is off. + * + *

Threading mirrors the other previews: {@link #setKeyboard} runs on the keyboard-view layout + * path and {@link #drawPreview} on {@code DrawingPreviewPlacerView}'s {@code onDraw}, both on the + * main thread. Repaints between keystrokes are driven by {@link AdaptiveKeyContext}'s change + * listener (also fired on the main thread), wired up by {@code MainKeyboardView}. + */ +public final class AdaptiveTargetsDrawingPreview extends AbstractDrawingPreview { + // Halo radius for a fully-agreed prior (weight 1.0), as a fraction of key width. Deliberately + // larger than KeyDetector's PRIOR_MAX_FRACTION (0.18) — this is a visualization, so the bulge + // is exaggerated to read clearly; smaller weights scale down linearly. + private static final float HALO_MAX_FRACTION = 0.55f; + private static final float GEOM_DOT_RADIUS_PX = 3f; + private static final float EFF_DOT_RADIUS_PX = 6f; + private static final float MIN_ARROW_LENGTH_PX = 1.5f; + + /** Current keyboard, set on the layout path; iterated for keys at draw time. */ + private Keyboard mKeyboard; + /** Keyboard-view padding: keys render at {@code getX()+paddingLeft, getY()+paddingTop}, and the + * placer canvas is already translated to the keyboard-view origin, so we add padding here. */ + private int mPaddingLeft; + private int mPaddingTop; + + private final Paint mGeomPaint = new Paint(); // reference: geometric key centre + private final Paint mArrowPaint = new Paint(); // shift from centre to learned target + private final Paint mEffPaint = new Paint(); // learned landing target + private final Paint mHaloFill = new Paint(); // prior bulge (fill) + private final Paint mHaloStroke = new Paint(); // prior bulge (outline) + + public AdaptiveTargetsDrawingPreview() { + mGeomPaint.setAntiAlias(true); + mGeomPaint.setColor(Color.WHITE); + mGeomPaint.setAlpha(0x66); + mGeomPaint.setStyle(Paint.Style.STROKE); + mGeomPaint.setStrokeWidth(2f); + + mArrowPaint.setAntiAlias(true); + mArrowPaint.setColor(Color.rgb(255, 171, 0)); // amber + mArrowPaint.setAlpha(0xCC); + mArrowPaint.setStyle(Paint.Style.STROKE); + mArrowPaint.setStrokeWidth(3f); + mArrowPaint.setStrokeCap(Paint.Cap.ROUND); + + mEffPaint.setAntiAlias(true); + mEffPaint.setColor(Color.rgb(255, 171, 0)); // amber + mEffPaint.setAlpha(0xEE); + mEffPaint.setStyle(Paint.Style.FILL); + + mHaloFill.setAntiAlias(true); + mHaloFill.setColor(Color.rgb(0, 200, 83)); // green = "predicted, easier to hit" + mHaloFill.setStyle(Paint.Style.FILL); + + mHaloStroke.setAntiAlias(true); + mHaloStroke.setColor(Color.rgb(0, 200, 83)); + mHaloStroke.setStyle(Paint.Style.STROKE); + mHaloStroke.setStrokeWidth(2.5f); + } + + /** Hand the overlay the current keyboard and the view padding at which keys are rendered. */ + public void setKeyboard(final Keyboard keyboard, final int paddingLeft, final int paddingTop) { + mKeyboard = keyboard; + mPaddingLeft = paddingLeft; + mPaddingTop = paddingTop; + invalidateDrawingView(); + } + + /** Repaint hook for {@link AdaptiveKeyContext}'s change listener (fires on each keystroke). */ + public void onAdaptiveContextChanged() { + invalidateDrawingView(); + } + + @Override + public void onDeallocateMemory() { + mKeyboard = null; + } + + @Override + public void setPreviewPosition(@NonNull final PointerTracker tracker) { + // No-op: the overlay is derived from the keyboard + model, not a single pointer. + } + + @Override + public void drawPreview(@NonNull final Canvas canvas) { + if (!isPreviewEnabled()) return; // geometry valid + final Keyboard keyboard = mKeyboard; + if (keyboard == null) return; + final SettingsValues sv = Settings.getValues(); + if (sv == null || !sv.mAdaptiveDebugOverlay) return; + final boolean learn = sv.mAdaptiveKeyGeometry; + final boolean prior = sv.mAdaptiveContextPrior; + if (!learn && !prior) return; + final int strength = sv.mAdaptiveKeyGeometryStrength; + + // Learned-offset lookup is keyed by layout + orientation, like KeyDetector. + TouchModelDao dao = null; + String layout = null; + int orientation = 0; + if (learn) { + final Context ctx = Settings.getCurrentContext(); + if (ctx != null) { + dao = TouchModelDao.getInstance(ctx); + layout = Integer.toString(keyboard.mId.mElementId); + orientation = ctx.getResources().getConfiguration().orientation; + } + } + + for (final Key key : keyboard.getSortedKeys()) { + if (key.isSpacer()) continue; + final int code = key.getCode(); + if (code <= 0 || !Character.isLetter(code)) continue; + final int w = key.getWidth(); + final int h = key.getHeight(); + if (w <= 0 || h <= 0) continue; + final float cx = mPaddingLeft + key.getX() + w / 2f; + final float cy = mPaddingTop + key.getY() + h / 2f; + + float effX = cx; + float effY = cy; + boolean haveLearned = false; + if (dao != null) { + final TouchModelDao.Stat st = dao.get(code, layout, orientation); + if (st != null && st.getCount() >= TouchModelDao.MIN_CONFIDENT_SAMPLES) { + final float[] off = TouchModelManager.adjustedOffset(st, w, h, strength, + System.currentTimeMillis(), sv.adaptiveForgetHalfLifeMs()); + effX = cx + off[0]; + effY = cy + off[1]; + haveLearned = true; + } + } + + // Prior halo first, so the learned dot/arrow sit on top of it. + if (prior) { + final float weight = AdaptiveKeyContext.weight(code); + if (weight > 0f) { + final float r = weight * HALO_MAX_FRACTION * w; + mHaloFill.setAlpha(0x33); + canvas.drawCircle(effX, effY, r, mHaloFill); + mHaloStroke.setAlpha(0x99); + canvas.drawCircle(effX, effY, r, mHaloStroke); + } + } + + if (haveLearned) { + canvas.drawCircle(cx, cy, GEOM_DOT_RADIUS_PX, mGeomPaint); + final float dx = effX - cx; + final float dy = effY - cy; + if (dx * dx + dy * dy > MIN_ARROW_LENGTH_PX * MIN_ARROW_LENGTH_PX) { + canvas.drawLine(cx, cy, effX, effY, mArrowPaint); + } + canvas.drawCircle(effX, effY, EFF_DOT_RADIUS_PX, mEffPaint); + } + } + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index a670093a7..f3518b5de 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -294,8 +294,25 @@ public void handleMessage(@NonNull final Message msg) { } public void postUpdateSuggestionStrip(final int inputStyle) { + long delay = mDelayInMillisecondsToUpdateSuggestions; + // While the debug overlay + context prior are both on, compute the prediction + // immediately (no debounce) so the visualization keeps up with fast typing. The overlay + // repaints the instant the prior updates, so "no debounce" is the safe equivalent of + // "update as soon as the suggestion is made". The remaining floor is the suggestion + // compute itself, which briefly blocks the UI thread by design (see + // performUpdateSuggestionStripSync) — fast on release, slower on debug builds. Scoped to + // the overlay so ordinary typing keeps the full, smooth debounce. + if (inputStyle == SuggestedWords.INPUT_STYLE_TYPING) { + final LatinIME latinIme = getOwnerInstance(); + if (latinIme != null) { + final SettingsValues sv = latinIme.mSettings.getCurrent(); + if (sv != null && sv.mAdaptiveDebugOverlay && sv.mAdaptiveContextPrior) { + delay = 0; + } + } + } sendMessageDelayed(obtainMessage(MSG_UPDATE_SUGGESTION_STRIP, inputStyle, - 0 /* ignored */), mDelayInMillisecondsToUpdateSuggestions); + 0 /* ignored */), delay); } public void postReopenDictionaries() { diff --git a/app/src/main/java/helium314/keyboard/latin/database/Database.kt b/app/src/main/java/helium314/keyboard/latin/database/Database.kt index 0e9c785cd..f1ec827af 100644 --- a/app/src/main/java/helium314/keyboard/latin/database/Database.kt +++ b/app/src/main/java/helium314/keyboard/latin/database/Database.kt @@ -10,17 +10,26 @@ import java.io.File class Database private constructor(context: Context, name: String = NAME) : SQLiteOpenHelper(context, name, null, VERSION) { override fun onCreate(db: SQLiteDatabase) { db.execSQL(ClipboardDao.CREATE_TABLE) + db.execSQL(TouchModelDao.CREATE_TABLE) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { if (oldVersion < 2) { db.execSQL("ALTER TABLE CLIPBOARD ADD COLUMN IMAGE_URI TEXT") } + if (oldVersion < 4) { + // The learned touch model is experimental and disposable, so on any upgrade from + // before it stabilized we just recreate it with the current schema instead of + // carrying per-version column migrations. No real release shipped the table, so + // this loses nothing in practice. + db.execSQL("DROP TABLE IF EXISTS ${TouchModelDao.TABLE}") + db.execSQL(TouchModelDao.CREATE_TABLE) + } } companion object { private val TAG = Database::class.java.simpleName - private const val VERSION = 2 + private const val VERSION = 4 const val NAME = "leantype.db" private var instance: Database? = null fun getInstance(context: Context): Database { @@ -55,6 +64,40 @@ class Database private constructor(context: Context, name: String = NAME) : SQLi clipDao.addClip(it.getLong(0), it.getInt(1) != 0, it.getString(2) ?: "", imageUri) } } + // Touch model (adaptive typing): present only in backups from versions that have it. + val touchDao = TouchModelDao.getInstance(context) + if (touchDao != null) { + val hasTouchModel = otherDb.readableDatabase.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name='${TouchModelDao.TABLE}'", null + ).use { it.moveToNext() } + if (hasTouchModel) { + touchDao.clear() + // Read by column name so an older backup that lacks the key-size columns + // restores fine (those default to 0). + otherDb.readableDatabase.rawQuery("SELECT * FROM ${TouchModelDao.TABLE}", null).use { c -> + val iCode = c.getColumnIndex("KEY_CODE") + val iLayout = c.getColumnIndex("LAYOUT") + val iOrient = c.getColumnIndex("ORIENTATION") + val iMdx = c.getColumnIndex("MEAN_DX") + val iMdy = c.getColumnIndex("MEAN_DY") + val iVdx = c.getColumnIndex("VAR_DX") + val iVdy = c.getColumnIndex("VAR_DY") + val iCount = c.getColumnIndex("COUNT") + val iUpd = c.getColumnIndex("UPDATED_AT") + val iW = c.getColumnIndex(TouchModelDao.COLUMN_KEY_WIDTH) + val iH = c.getColumnIndex(TouchModelDao.COLUMN_KEY_HEIGHT) + if (iCode >= 0 && iLayout >= 0 && iOrient >= 0) { + while (c.moveToNext()) { + touchDao.restore(TouchModelDao.Stat( + c.getInt(iCode), c.getString(iLayout), c.getInt(iOrient), + c.getFloat(iMdx), c.getFloat(iMdy), c.getFloat(iVdx), c.getFloat(iVdy), + c.getInt(iCount), c.getLong(iUpd), + if (iW >= 0) c.getInt(iW) else 0, if (iH >= 0) c.getInt(iH) else 0)) + } + } + } + } + } otherDb.close() file.delete() } diff --git a/app/src/main/java/helium314/keyboard/latin/database/TouchModelDao.kt b/app/src/main/java/helium314/keyboard/latin/database/TouchModelDao.kt new file mode 100644 index 000000000..2742b9224 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/database/TouchModelDao.kt @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.database + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import helium314.keyboard.latin.utils.Log +import java.util.concurrent.Executors + +/** + * Cached access to the learned per-user touch-model table (see docs/ADAPTIVE_TYPING.md). + * + * Stores, per (key code, layout, orientation), the running mean landing offset relative to the + * key center and its variance, plus a sample count. **Content-free**: no characters/words are + * stored, only aggregate touch geometry. Used to bias tap resolution (KeyDetector) and gesture + * sweet spots (ProximityInfo) toward where the user actually types. + * + * Lives in the shared [Database] ("leantype.db"), so it rides the existing settings + * backup/restore. Lookups are O(1) from an in-memory cache for the input hot path. + */ +class TouchModelDao private constructor(private val db: Database) { + + /** One key's learned stats. Offsets/variance are in pixels (relative to the key center). + * keyWidth/keyHeight are the key's size in px at record time, so a viewer can express the + * offset as a fraction of the key (e.g. "18px = 20% toward the lower-left of E"). */ + data class Stat( + val keyCode: Int, + val layout: String, + val orientation: Int, + var meanDx: Float, + var meanDy: Float, + var varDx: Float, + var varDy: Float, + var count: Int, + var updatedAt: Long, + var keyWidth: Int = 0, + var keyHeight: Int = 0, + ) + + private val cache = HashMap() + // Persist off the input thread: record() runs on every letter tap, so the DB write must not + // block typing. The cache (source of truth at runtime) is updated synchronously; the disk + // write is serialized on this single thread (order preserved). Losing the last sample or two + // on a crash is acceptable for a learning model. + private val writeExecutor = Executors.newSingleThreadExecutor() + + init { + db.readableDatabase.query( + TABLE, + arrayOf(COLUMN_KEY_CODE, COLUMN_LAYOUT, COLUMN_ORIENTATION, COLUMN_MEAN_DX, COLUMN_MEAN_DY, + COLUMN_VAR_DX, COLUMN_VAR_DY, COLUMN_COUNT, COLUMN_UPDATED_AT, COLUMN_KEY_WIDTH, COLUMN_KEY_HEIGHT), + null, null, null, null, null + ).use { + while (it.moveToNext()) { + val s = Stat(it.getInt(0), it.getString(1), it.getInt(2), it.getFloat(3), it.getFloat(4), + it.getFloat(5), it.getFloat(6), it.getInt(7), it.getLong(8), it.getInt(9), it.getInt(10)) + cache[key(s.keyCode, s.layout, s.orientation)] = s + } + } + } + + /** + * Fold a new landing offset (touch position minus key center, in px) into the running model + * for this key, using an exponential moving average so recent behavior is weighted more and + * old data decays. Persists immediately. Callers must gate on incognito + the opt-in pref. + */ + @Synchronized + fun record(keyCode: Int, layout: String, orientation: Int, dx: Float, dy: Float, + keyWidth: Int, keyHeight: Int, now: Long, halfLifeMs: Long) { + val k = key(keyCode, layout, orientation) + val s = cache[k] + if (s == null) { + val ns = Stat(keyCode, layout, orientation, dx, dy, 0f, 0f, 1, now, keyWidth, keyHeight) + cache[k] = ns + persistAsync(ns.copy()) + return + } + // Forget window: fade the accumulated confidence by wall-clock elapsed BEFORE folding in + // the new sample, so after a long gap a single tap can't restore full confidence in + // months-old data — the model rebuilds gradually. (The mean EMA below is unaffected; only + // the sample count, which gates how much the bias is applied, decays.) + if (halfLifeMs > 0L && s.updatedAt in 1 until now) { + s.count = Math.round(s.count * TouchModelManager.decayFactor(now - s.updatedAt, halfLifeMs)) + } + val a = EMA_ALPHA + val oldMeanDx = s.meanDx + val oldMeanDy = s.meanDy + s.meanDx = (1 - a) * oldMeanDx + a * dx + s.meanDy = (1 - a) * oldMeanDy + a * dy + // Exponentially-weighted variance around the pre-update mean. + s.varDx = (1 - a) * (s.varDx + a * (dx - oldMeanDx) * (dx - oldMeanDx)) + s.varDy = (1 - a) * (s.varDy + a * (dy - oldMeanDy) * (dy - oldMeanDy)) + if (s.count < Int.MAX_VALUE) s.count++ + s.updatedAt = now + if (keyWidth > 0) s.keyWidth = keyWidth + if (keyHeight > 0) s.keyHeight = keyHeight + persistAsync(s.copy()) + } + + private fun persistAsync(snapshot: Stat) { + try { + writeExecutor.execute { write(snapshot) } + } catch (e: Throwable) { + Log.e(TAG, "touch model write rejected", e) + } + } + + /** Learned stats for a key, or null if none recorded yet. */ + @Synchronized + fun get(keyCode: Int, layout: String, orientation: Int): Stat? = + cache[key(keyCode, layout, orientation)] + + /** Snapshot of all stats (for the stats page / debugging). */ + @Synchronized + fun all(): List = cache.values.map { it.copy() } + + /** Insert a full stat verbatim (no EMA) — used when restoring from a backup. */ + @Synchronized + fun restore(s: Stat) { + cache[key(s.keyCode, s.layout, s.orientation)] = s + // Route through the same executor as record()/clear() so every DB mutation shares one + // ordering domain — no synchronous write racing the queued async writes. + persistAsync(s) + } + + /** Forget everything (the "reset learned typing model" action). */ + @Synchronized + fun clear() { + if (cache.isEmpty()) return + cache.clear() + // Delete on the SAME single-thread executor that record() writes on, so any write queued + // just before Reset is applied first and the delete wins. A synchronous delete here could + // be overtaken by an in-flight async write that re-inserts a row, silently resurrecting the + // data the user asked to forget (it would reload into the cache on the next launch). + try { + writeExecutor.execute { db.writableDatabase.delete(TABLE, null, null) } + } catch (e: Throwable) { + Log.e(TAG, "touch model clear rejected", e) + } + } + + private fun write(s: Stat) { + val cv = ContentValues(11) + cv.put(COLUMN_KEY_CODE, s.keyCode) + cv.put(COLUMN_LAYOUT, s.layout) + cv.put(COLUMN_ORIENTATION, s.orientation) + cv.put(COLUMN_MEAN_DX, s.meanDx) + cv.put(COLUMN_MEAN_DY, s.meanDy) + cv.put(COLUMN_VAR_DX, s.varDx) + cv.put(COLUMN_VAR_DY, s.varDy) + cv.put(COLUMN_COUNT, s.count) + cv.put(COLUMN_UPDATED_AT, s.updatedAt) + cv.put(COLUMN_KEY_WIDTH, s.keyWidth) + cv.put(COLUMN_KEY_HEIGHT, s.keyHeight) + db.writableDatabase.insertWithOnConflict(TABLE, null, cv, SQLiteDatabase.CONFLICT_REPLACE) + } + + companion object { + private const val TAG = "TouchModelDao" + // ~ last 20 samples dominate; tuned later on-device. + private const val EMA_ALPHA = 0.05f + /** Below this many samples a key's learned bias should not be applied (confidence gate). */ + const val MIN_CONFIDENT_SAMPLES = 20 + + const val TABLE = "TOUCH_MODEL" + private const val COLUMN_KEY_CODE = "KEY_CODE" + private const val COLUMN_LAYOUT = "LAYOUT" + private const val COLUMN_ORIENTATION = "ORIENTATION" + private const val COLUMN_MEAN_DX = "MEAN_DX" + private const val COLUMN_MEAN_DY = "MEAN_DY" + private const val COLUMN_VAR_DX = "VAR_DX" + private const val COLUMN_VAR_DY = "VAR_DY" + private const val COLUMN_COUNT = "COUNT" + private const val COLUMN_UPDATED_AT = "UPDATED_AT" + const val COLUMN_KEY_WIDTH = "KEY_WIDTH" + const val COLUMN_KEY_HEIGHT = "KEY_HEIGHT" + const val CREATE_TABLE = """ + CREATE TABLE $TABLE ( + $COLUMN_KEY_CODE INTEGER NOT NULL, + $COLUMN_LAYOUT TEXT NOT NULL, + $COLUMN_ORIENTATION INTEGER NOT NULL, + $COLUMN_MEAN_DX REAL NOT NULL, + $COLUMN_MEAN_DY REAL NOT NULL, + $COLUMN_VAR_DX REAL NOT NULL, + $COLUMN_VAR_DY REAL NOT NULL, + $COLUMN_COUNT INTEGER NOT NULL, + $COLUMN_UPDATED_AT INTEGER NOT NULL, + $COLUMN_KEY_WIDTH INTEGER NOT NULL DEFAULT 0, + $COLUMN_KEY_HEIGHT INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY ($COLUMN_KEY_CODE, $COLUMN_LAYOUT, $COLUMN_ORIENTATION) + ) + """ + + private fun key(keyCode: Int, layout: String, orientation: Int) = "$keyCode|$layout|$orientation" + + private var instance: TouchModelDao? = null + + /** Returns the instance, or null if it can't be created (e.g. device locked). */ + @JvmStatic + @Synchronized + fun getInstance(context: Context): TouchModelDao? { + if (instance == null) + try { + instance = TouchModelDao(Database.getInstance(context)) + } catch (e: Throwable) { + Log.e(TAG, "can't create TouchModelDao", e) + } + return instance + } + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/database/TouchModelManager.kt b/app/src/main/java/helium314/keyboard/latin/database/TouchModelManager.kt new file mode 100644 index 000000000..5a9eb7558 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/database/TouchModelManager.kt @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.database + +/** + * Policy layer over [TouchModelDao]: turns a key's raw learned stats into a *capped*, + * confidence- and strength-scaled landing offset that the input paths apply + * (see docs/ADAPTIVE_TYPING.md). Pure functions — no Android / DB / threading — so the + * caps and ramp are unit-testable and the input hot paths just consume the result. + * + * The cap is the safety guarantee: a learned bias can shift a key's effective center by at + * most [MAX_SHIFT_FRACTION] of the key dimension, so a clearly-on-target press can never flip + * to a neighbor. The bias also ramps in with sample count (no sudden jumps from sparse data) + * and scales with the user's strength setting (0 = off). + */ +object TouchModelManager { + /** Max center shift as a fraction of the key's width/height. */ + const val MAX_SHIFT_FRACTION = 0.25f + /** Confidence reaches full strength at this many samples; it is 0 at/below MIN_CONFIDENT_SAMPLES. */ + const val FULL_CONFIDENCE_SAMPLES = 60 + + /** + * Capped, confidence- and strength-scaled landing offset for a key, in pixels. + * @return a fresh {dx, dy}; {0, 0} when there is not enough data or strength is 0. + */ + /** Offset with NO recency decay (forget window off). */ + @JvmStatic + fun adjustedOffset(stat: TouchModelDao.Stat?, keyWidth: Int, keyHeight: Int, + strengthPercent: Int): FloatArray = + adjustedOffset(stat, keyWidth, keyHeight, strengthPercent, 0L, 0L) + + /** + * Capped, confidence- and strength-scaled landing offset for a key, in pixels, with optional + * wall-clock recency decay (the "forget window"). When [halfLifeMs] > 0 the key's effective + * sample weight is faded by how long since it was last updated, so stale learning gradually + * stops biasing — a moving window of recent typing. [halfLifeMs] <= 0 disables decay. + * @return a fresh {dx, dy}; {0, 0} when there is not enough (effective) data or strength is 0. + */ + @JvmStatic + fun adjustedOffset(stat: TouchModelDao.Stat?, keyWidth: Int, keyHeight: Int, + strengthPercent: Int, now: Long, halfLifeMs: Long): FloatArray { + if (stat == null || strengthPercent <= 0 || keyWidth <= 0 || keyHeight <= 0) { + return floatArrayOf(0f, 0f) + } + val effectiveCount = stat.count * decayFactor(now - stat.updatedAt, halfLifeMs) + val scale = confidence(effectiveCount) * (strengthPercent.coerceIn(0, 100) / 100f) + if (scale <= 0f) return floatArrayOf(0f, 0f) + val maxX = MAX_SHIFT_FRACTION * keyWidth + val maxY = MAX_SHIFT_FRACTION * keyHeight + val dx = (stat.meanDx * scale).coerceIn(-maxX, maxX) + val dy = (stat.meanDy * scale).coerceIn(-maxY, maxY) + return floatArrayOf(dx, dy) + } + + /** + * Recency multiplier in [0,1] for data last touched [elapsedMs] ago given a [halfLifeMs] (data + * this old counts half; twice this old, a quarter; …). Returns 1 (no decay) when [halfLifeMs] + * <= 0 or elapsed <= 0. + */ + @JvmStatic + fun decayFactor(elapsedMs: Long, halfLifeMs: Long): Float { + if (halfLifeMs <= 0L || elapsedMs <= 0L) return 1f + return Math.pow(0.5, elapsedMs.toDouble() / halfLifeMs.toDouble()).toFloat().coerceIn(0f, 1f) + } + + /** 0 below [TouchModelDao.MIN_CONFIDENT_SAMPLES], ramps linearly to 1 at [FULL_CONFIDENCE_SAMPLES]. */ + fun confidence(count: Int): Float = confidence(count.toFloat()) + + fun confidence(count: Float): Float { + val min = TouchModelDao.MIN_CONFIDENT_SAMPLES + if (count <= min) return 0f + if (count >= FULL_CONFIDENCE_SAMPLES) return 1f + return (count - min) / (FULL_CONFIDENCE_SAMPLES - min).toFloat() + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java index 834cc1561..f8f64b035 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -1404,6 +1404,33 @@ public void setSuggestedWords(final SuggestedWords suggestedWords) { mWordComposer.setAutoCorrection(suggestedWordInfo); } mSuggestedWords = suggestedWords; + // Adaptive typing: refresh the "likely next key" prior from the strip so the next tap can + // bias toward likely keys. The next-character index is the current composing-word length + // (or 0 for a new word, where the predictions' first letters are the likely next keys). + // Cheap, and runs on suggestion updates rather than the tap hot path. + final SettingsValues svAdaptive = Settings.getValues(); + if (svAdaptive != null && svAdaptive.mAdaptiveContextPrior) { + final int nextCharIndex = mWordComposer.isComposingWord() + ? mWordComposer.getTypedWord().length() : 0; + helium314.keyboard.keyboard.AdaptiveKeyContext.update(suggestedWords, nextCharIndex); + if (svAdaptive.mAdaptiveDebugOverlay) { + final StringBuilder words = new StringBuilder(); + for (int i = 0; i < Math.min(5, suggestedWords.size()); i++) { + if (i > 0) words.append('|'); + words.append(suggestedWords.getWord(i)); + } + Log.d("AdaptivePrior", "setSuggested style=" + suggestedWords.mInputStyle + + " composing=" + mWordComposer.isComposingWord() + + " typed='" + mWordComposer.getTypedWord() + "'" + + " pos=" + nextCharIndex + + " words=[" + words + "]" + + " -> prior=" + helium314.keyboard.keyboard.AdaptiveKeyContext.debugString()); + } + } else { + helium314.keyboard.keyboard.AdaptiveKeyContext.clear(); + } + // #24 spacing-policy signals (upstream): independent per-keystroke signals derived from the + // same suggestion results; passive groundwork, consumed by the upcoming signal-driven grace. final SpacingSignals spacingSignals = computeSpacingSignals(suggestedWords); mSpacingComplete = spacingSignals.complete; mSpacingPrefixRichScore = spacingSignals.prefixRichScore; @@ -4011,6 +4038,9 @@ public void onUpdateTailBatchInputCompleted(final SettingsValues settingsValues, if (settingsValues.mMultipartRerecognizeTaps) { mLiveStroke.set(mWordComposer.getInputPointers()); } + // Adaptive typing: learn this gesture's clean endpoints so swipes teach the model too. + maybeRecordGestureEndpoints(settingsValues, composedText, extendExistingCompose, + usedMergedTrail, keyboardSwitcher); mWordComposer.setBatchInputWord(composedText); setComposingTextInternal(composedText, 1); if (extendExistingCompose) { @@ -4075,6 +4105,44 @@ public void onUpdateTailBatchInputCompleted(final SettingsValues settingsValues, enterCombiningMode(settingsValues, false /* fromTap, unused — kept for clarity */); } + // Adaptive typing (opt-in, see docs/ADAPTIVE_TYPING.md): a gesture's first point (finger-down) + // and last point (finger-up) are clean "I aimed here" samples for the word's first and last + // letters — unlike interior keys, which suffer corner-cutting. Fold those two offsets into the + // learned model so swipes teach it too. Only for fresh single strokes (merged/extended trails + // have ambiguous endpoints). Gated on the opt-in pref + incognito; the DAO write is async. + private void maybeRecordGestureEndpoints(final SettingsValues sv, final String word, + final boolean extend, final boolean usedMergedTrail, + final KeyboardSwitcher keyboardSwitcher) { + if (sv == null || !sv.mAdaptiveKeyGeometry || sv.mIncognitoModeEnabled) return; + if (extend || usedMergedTrail || word == null || word.isEmpty()) return; + final InputPointers pts = mWordComposer.getInputPointers(); + final int n = pts.getPointerSize(); + if (n < 2) return; // need a real stroke; taps are handled in PointerTracker + final Keyboard keyboard = keyboardSwitcher.getKeyboard(); + if (keyboard == null) return; + final int[] xs = pts.getXCoordinates(); + final int[] ys = pts.getYCoordinates(); + final long halfLifeMs = sv.adaptiveForgetHalfLifeMs(); + recordGestureEndpoint(keyboard, Character.toLowerCase(word.codePointAt(0)), xs[0], ys[0], halfLifeMs); + recordGestureEndpoint(keyboard, Character.toLowerCase(word.codePointBefore(word.length())), + xs[n - 1], ys[n - 1], halfLifeMs); + } + + private void recordGestureEndpoint(final Keyboard keyboard, final int codePoint, + final int x, final int y, final long halfLifeMs) { + if (!Character.isLetter(codePoint) || x < 0 || y < 0) return; + final helium314.keyboard.keyboard.Key key = keyboard.getKey(codePoint); + if (key == null) return; + final helium314.keyboard.latin.database.TouchModelDao dao = + helium314.keyboard.latin.database.TouchModelDao.getInstance(mLatinIME); + if (dao == null) return; + final android.graphics.Rect hitBox = key.getHitBox(); + dao.record(codePoint, Integer.toString(keyboard.mId.mElementId), + mLatinIME.getResources().getConfiguration().orientation, + x - hitBox.exactCenterX(), y - hitBox.exactCenterY(), + hitBox.width(), hitBox.height(), System.currentTimeMillis(), halfLifeMs); + } + /** * Commit the typed string to the editor. *

diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index c0e875464..b7d56111b 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -159,6 +159,14 @@ object Defaults { const val PREF_MULTIPART_FULL_WORD_SUGGESTIONS = true const val PREF_MULTIPART_TAP_SEED_GESTURE = true const val PREF_MULTIPART_RERECOGNIZE_TAPS = false + // Adaptive typing (opt-in). Off by default; strength is a 0..100 percentage of the cap. + const val PREF_ADAPTIVE_KEY_GEOMETRY = false + const val PREF_ADAPTIVE_KEY_GEOMETRY_STRENGTH = 50 + // Adaptive forget window in months: learned touch geometry fades by this half-life so the model + // tracks recent typing rather than an unbounded all-time average. 0 = never forget. + const val PREF_ADAPTIVE_FORGET_WINDOW_MONTHS = 3 + const val PREF_ADAPTIVE_CONTEXT_PRIOR = false + const val PREF_ADAPTIVE_DEBUG_OVERLAY = false const val PREF_SHOW_SETUP_WIZARD_ICON = true const val PREF_USE_CONTACTS = false const val PREF_USE_APPS = false diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index cf89a5379..d3257a6ee 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -189,6 +189,18 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang // re-recognize the whole word, instead of literally appending it to a (possibly // mis-resolved) fragment. Makes a slow tap-after-swipe behave like a fast one. Default off. public static final String PREF_MULTIPART_RERECOGNIZE_TAPS = "multipart_rerecognize_taps"; + // Adaptive typing (opt-in): learn per-user key landing offsets and bias both tap resolution + // and gesture sweet-spots toward where the user actually types. Content-free (geometry only), + // incognito-gated. Strength scales the cap (0 = off). See docs/ADAPTIVE_TYPING.md. + public static final String PREF_ADAPTIVE_KEY_GEOMETRY = "adaptive_key_geometry"; + public static final String PREF_ADAPTIVE_KEY_GEOMETRY_STRENGTH = "adaptive_key_geometry_strength"; + public static final String PREF_ADAPTIVE_FORGET_WINDOW_MONTHS = "adaptive_forget_window_months"; + // Context prior: enlarge the touch target of the next key the suggestions predict. Independent + // of the learned-geometry toggle above; both share the strength slider. See ADAPTIVE_TYPING.md. + public static final String PREF_ADAPTIVE_CONTEXT_PRIOR = "adaptive_context_prior"; + // Debug visualization: draw the effective per-key targets (learned-offset shift + prior bulge) + // on the live keyboard so the adaptive behavior is visible as you type. See ADAPTIVE_TYPING.md. + public static final String PREF_ADAPTIVE_DEBUG_OVERLAY = "adaptive_debug_overlay"; public static final String PREF_SHOW_SETUP_WIZARD_ICON = "show_setup_wizard_icon"; public static final String PREF_USE_CONTACTS = "use_contacts"; public static final String PREF_USE_APPS = "use_apps"; diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java index 95efd7756..71ae683c6 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -148,6 +148,14 @@ public class SettingsValues { public final boolean mMultipartFullWordSuggestions; public final boolean mMultipartTapSeedGesture; public final boolean mMultipartRerecognizeTaps; + // Adaptive typing (opt-in): learn where the user lands and bias tap/gesture key geometry. + public final boolean mAdaptiveKeyGeometry; + public final int mAdaptiveKeyGeometryStrength; + // Forget window (months): learned touch geometry older than ~this is faded out, so the + // model tracks a moving window of recent typing rather than an unbounded all-time average. + public final int mAdaptiveForgetWindowMonths; + public final boolean mAdaptiveContextPrior; + public final boolean mAdaptiveDebugOverlay; public final boolean mSlidingKeyInputPreviewEnabled; public final boolean mRecordInputTraces; public final int mKeyLongpressTimeout; @@ -411,6 +419,21 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina mMultipartRerecognizeTaps = prefs.getBoolean( Settings.PREF_MULTIPART_RERECOGNIZE_TAPS, Defaults.PREF_MULTIPART_RERECOGNIZE_TAPS); + mAdaptiveKeyGeometry = prefs.getBoolean( + Settings.PREF_ADAPTIVE_KEY_GEOMETRY, + Defaults.PREF_ADAPTIVE_KEY_GEOMETRY); + mAdaptiveKeyGeometryStrength = prefs.getInt( + Settings.PREF_ADAPTIVE_KEY_GEOMETRY_STRENGTH, + Defaults.PREF_ADAPTIVE_KEY_GEOMETRY_STRENGTH); + mAdaptiveForgetWindowMonths = prefs.getInt( + Settings.PREF_ADAPTIVE_FORGET_WINDOW_MONTHS, + Defaults.PREF_ADAPTIVE_FORGET_WINDOW_MONTHS); + mAdaptiveContextPrior = prefs.getBoolean( + Settings.PREF_ADAPTIVE_CONTEXT_PRIOR, + Defaults.PREF_ADAPTIVE_CONTEXT_PRIOR); + mAdaptiveDebugOverlay = prefs.getBoolean( + Settings.PREF_ADAPTIVE_DEBUG_OVERLAY, + Defaults.PREF_ADAPTIVE_DEBUG_OVERLAY); mSuggestionStripHiddenPerUserSettings = mToolbarMode == ToolbarMode.HIDDEN || mToolbarMode == ToolbarMode.TOOLBAR_KEYS; mOverrideShowingSuggestions = mInputAttributes.mMayOverrideShowingSuggestions @@ -548,6 +571,12 @@ public boolean isApplicationSpecifiedCompletionsOn() { return mInputAttributes.mApplicationSpecifiedCompletionOn; } + /** Half-life (ms) for the adaptive forget window, or 0 when disabled (window <= 0). */ + public long adaptiveForgetHalfLifeMs() { + return mAdaptiveForgetWindowMonths <= 0 ? 0L + : (long) mAdaptiveForgetWindowMonths * 30L * 24L * 60L * 60L * 1000L; + } + public boolean needsToLookupSuggestions() { return (mInputAttributes.mShouldShowSuggestions || mOverrideShowingSuggestions) && (mAutoCorrectEnabled || mSuggestionsEnabledPerUserSettings); diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt index 5dc52e6a5..6f4097e32 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt @@ -96,6 +96,8 @@ object SettingsWithoutKey { const val LOAD_GESTURE_LIB = "load_gesture_library" const val TWO_THUMB_SPACING_MODE = "two_thumb_spacing_mode" const val TWO_THUMB_BACKSPACE_BEHAVIOR = "two_thumb_backspace_behavior" + const val ADAPTIVE_TYPING_STATS = "adaptive_typing_stats" // entry row (navigates) + const val ADAPTIVE_TYPING_STATS_CONTENT = "adaptive_typing_stats_content" // the stats body const val BACKGROUND_IMAGE = "background_image" const val BACKGROUND_IMAGE_LANDSCAPE = "background_image_landscape" const val CUSTOM_FONT = "custom_font" diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt index 2697a9d2f..fe7832697 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt @@ -18,6 +18,7 @@ import helium314.keyboard.latin.settings.SettingsSubtype.Companion.toSettingsSub import helium314.keyboard.latin.settings.getTransitionAnimationScale import helium314.keyboard.settings.screens.AIIntegrationScreen import helium314.keyboard.settings.screens.AboutScreen +import helium314.keyboard.settings.screens.AdaptiveTypingStatsScreen import helium314.keyboard.settings.screens.AdvancedSettingsScreen import helium314.keyboard.settings.screens.AppearanceScreen import helium314.keyboard.settings.screens.ColorsScreen @@ -107,6 +108,9 @@ fun SettingsNavHost( composable(SettingsDestination.TwoThumbTyping) { TwoThumbTypingScreen(onClickBack = ::goBack) } + composable(SettingsDestination.AdaptiveTypingStats) { + AdaptiveTypingStatsScreen(onClickBack = ::goBack) + } composable(SettingsDestination.Advanced) { AdvancedSettingsScreen(onClickBack = ::goBack) } @@ -187,6 +191,7 @@ object SettingsDestination { const val Toolbar = "toolbar" const val GestureTyping = "gesture_typing" const val TwoThumbTyping = "two_thumb_typing" + const val AdaptiveTypingStats = "adaptive_typing_stats_screen" const val Advanced = "advanced" const val Libraries = "libraries_hub" const val AIIntegration = "ai_integration" diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdaptiveTypingStatsScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdaptiveTypingStatsScreen.kt new file mode 100644 index 000000000..89ecf683d --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/AdaptiveTypingStatsScreen.kt @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.settings.screens + +import android.content.Context +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import helium314.keyboard.latin.R +import helium314.keyboard.latin.database.TouchModelDao +import helium314.keyboard.settings.SearchSettingsScreen +import helium314.keyboard.settings.SettingsWithoutKey +import kotlin.math.sqrt + +/** + * "Learned typing model" page: shows what the adaptive-key-geometry feature has learned per key + * (mean landing offset, spread/consistency, sample count) and lets the user reset it. Reuses the + * standard settings scaffold by rendering one content [Setting]; see [AdaptiveTypingStatsContent]. + */ +@Composable +fun AdaptiveTypingStatsScreen(onClickBack: () -> Unit) { + SearchSettingsScreen( + onClickBack = onClickBack, + title = stringResource(R.string.adaptive_key_geometry_stats_title), + settings = listOf(SettingsWithoutKey.ADAPTIVE_TYPING_STATS_CONTENT) + ) +} + +private fun loadStats(context: Context): List { + val dao = TouchModelDao.getInstance(context) ?: return emptyList() + return dao.all().sortedByDescending { it.count } +} + +/** A mock QWERTY keyboard with, on each key, a dot showing where the user tends to land + * (offset from the key center, as a fraction of the key) and a faint ring for the spread. + * Confident keys (enough samples) are drawn in the accent color, still-learning keys faded. */ +@Composable +private fun MockKeyboardHeatmap(stats: List) { + val orientation = LocalConfiguration.current.orientation + val pref = stats.filter { it.orientation == orientation } + // Key by the LOWER-CASE code and keep the highest-confidence stat per physical key, so taps + // recorded under a shifted (upper-case) code still render on the lower-case heat-map cells. + val byCode = (if (pref.isNotEmpty()) pref else stats) + .groupBy { Character.toLowerCase(it.keyCode) } + .mapValues { (_, list) -> list.maxByOrNull { it.count }!! } + + val keyBg = MaterialTheme.colorScheme.surfaceVariant + val labelColor = MaterialTheme.colorScheme.onSurfaceVariant + val accent = MaterialTheme.colorScheme.primary + val faded = MaterialTheme.colorScheme.outline + val rows = listOf("qwertyuiop", "asdfghjkl", "zxcvbnm") + val labelPaint = remember { + android.graphics.Paint().apply { + isAntiAlias = true + textAlign = android.graphics.Paint.Align.CENTER + } + } + + Canvas( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(10f / (3f * 1.35f)) + .padding(vertical = 8.dp) + ) { + val cellW = size.width / 10f + val cellH = cellW * 1.35f + labelPaint.color = labelColor.toArgb() + labelPaint.textSize = cellW * 0.42f + rows.forEachIndexed { r, row -> + val startX = (size.width - row.length * cellW) / 2f + val top = r * cellH + row.forEachIndexed { i, ch -> + val left = startX + i * cellW + val cx = left + cellW / 2f + val cy = top + cellH / 2f + drawRoundRect( + color = keyBg, + topLeft = Offset(left + 3f, top + 3f), + size = Size(cellW - 6f, cellH - 6f), + cornerRadius = CornerRadius(8f, 8f) + ) + drawContext.canvas.nativeCanvas.drawText( + ch.uppercase(), cx, + cy - (labelPaint.ascent() + labelPaint.descent()) / 2f, labelPaint + ) + val s = byCode[ch.code] + if (s != null && s.keyWidth > 0 && s.keyHeight > 0) { + val fx = (s.meanDx / s.keyWidth).coerceIn(-0.5f, 0.5f) + val fy = (s.meanDy / s.keyHeight).coerceIn(-0.5f, 0.5f) + val dotX = cx + fx * cellW + val dotY = cy + fy * cellH + val col = if (s.count >= TouchModelDao.MIN_CONFIDENT_SAMPLES) accent else faded + val spreadFrac = sqrt( + (((s.varDx / (s.keyWidth.toFloat() * s.keyWidth)) + + (s.varDy / (s.keyHeight.toFloat() * s.keyHeight))) / 2f).coerceAtLeast(0f) + ).coerceIn(0f, 0.5f) + if (spreadFrac > 0f) + drawCircle(col.copy(alpha = 0.15f), spreadFrac * cellW, Offset(dotX, dotY)) + drawLine(col, Offset(cx, cy), Offset(dotX, dotY), strokeWidth = 2.5f) + drawCircle(col, cellW * 0.07f, Offset(dotX, dotY)) + } + } + } + } +} + +/** The dynamic stats body. Rendered as the content of a single registered Setting. */ +@Composable +fun AdaptiveTypingStatsContent() { + val context = LocalContext.current + var stats by remember { mutableStateOf(loadStats(context)) } + Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) { + Text( + stringResource(R.string.adaptive_key_geometry_stats_explanation), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp) + ) + if (stats.isEmpty()) { + Text( + stringResource(R.string.adaptive_key_geometry_stats_empty), + modifier = Modifier.padding(vertical = 8.dp) + ) + } else { + MockKeyboardHeatmap(stats) + Text( + stringResource(R.string.adaptive_key_geometry_stats_legend), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp) + ) + stats.forEach { s -> + val letter = try { String(Character.toChars(s.keyCode)) } catch (e: Throwable) { "?" } + val orient = if (s.orientation == 2) "L" else "P" // 2 == landscape + val spread = sqrt(((s.varDx + s.varDy) / 2f).coerceAtLeast(0f)).toInt() + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("$letter ($orient)", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodyLarge) + Text( + "Δ(${s.meanDx.toInt()}, ${s.meanDy.toInt()})px ±${spread}px n=${s.count}", + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + Button( + onClick = { + TouchModelDao.getInstance(context)?.clear() + stats = emptyList() + }, + modifier = Modifier.padding(top = 16.dp) + ) { + Text(stringResource(R.string.adaptive_key_geometry_reset)) + } + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt index bb9d310e0..e3c1393a3 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt @@ -19,6 +19,8 @@ import helium314.keyboard.latin.utils.prefs import helium314.keyboard.settings.Setting import helium314.keyboard.settings.SearchSettingsScreen import helium314.keyboard.settings.SettingsActivity +import helium314.keyboard.settings.SettingsDestination +import helium314.keyboard.settings.preferences.Preference import helium314.keyboard.settings.preferences.SliderPreference import helium314.keyboard.settings.preferences.SwitchPreference import helium314.keyboard.settings.Theme @@ -79,6 +81,17 @@ fun GestureTypingScreen( add(Settings.PREF_SHORTCUT_TOP_ROW) add(Settings.PREF_SHORTCUT_BOTTOM_ROW) } + + // Adaptive typing — both sub-features grouped in one section. + add(R.string.adaptive_typing_category) + add(Settings.PREF_ADAPTIVE_KEY_GEOMETRY) + add(Settings.PREF_ADAPTIVE_CONTEXT_PRIOR) + val learnOn = prefs.getBoolean(Settings.PREF_ADAPTIVE_KEY_GEOMETRY, Defaults.PREF_ADAPTIVE_KEY_GEOMETRY) + val priorOn = prefs.getBoolean(Settings.PREF_ADAPTIVE_CONTEXT_PRIOR, Defaults.PREF_ADAPTIVE_CONTEXT_PRIOR) + if (learnOn || priorOn) add(Settings.PREF_ADAPTIVE_KEY_GEOMETRY_STRENGTH) + if (learnOn) add(Settings.PREF_ADAPTIVE_FORGET_WINDOW_MONTHS) // only the learned half stores geometry + if (learnOn || priorOn) add(Settings.PREF_ADAPTIVE_DEBUG_OVERLAY) // visualize the effect on the live keyboard + if (learnOn) add(SettingsWithoutKey.ADAPTIVE_TYPING_STATS) // the learned model only fills with the geometry half } SearchSettingsScreen( onClickBack = onClickBack, @@ -176,6 +189,52 @@ fun createGestureTypingSettings(context: Context) = listOf( Setting(context, Settings.PREF_DELETE_SWIPE, R.string.delete_swipe, R.string.delete_swipe_summary) { SwitchPreference(it, Defaults.PREF_DELETE_SWIPE) }, + Setting(context, Settings.PREF_ADAPTIVE_KEY_GEOMETRY, + R.string.adaptive_key_geometry, R.string.adaptive_key_geometry_summary) { + SwitchPreference(it, Defaults.PREF_ADAPTIVE_KEY_GEOMETRY) { + KeyboardSwitcher.getInstance().setThemeNeedsReload() // rebuild keyboard so sweet spots refresh + } + }, + Setting(context, Settings.PREF_ADAPTIVE_CONTEXT_PRIOR, + R.string.adaptive_context_prior, R.string.adaptive_context_prior_summary) { + SwitchPreference(it, Defaults.PREF_ADAPTIVE_CONTEXT_PRIOR) + }, + Setting(context, Settings.PREF_ADAPTIVE_DEBUG_OVERLAY, + R.string.adaptive_debug_overlay, R.string.adaptive_debug_overlay_summary) { + SwitchPreference(it, Defaults.PREF_ADAPTIVE_DEBUG_OVERLAY) { + KeyboardSwitcher.getInstance().setThemeNeedsReload() // rebuild so the overlay attaches/detaches at once + } + }, + Setting(context, Settings.PREF_ADAPTIVE_KEY_GEOMETRY_STRENGTH, R.string.adaptive_key_geometry_strength) { def -> + SliderPreference( + name = def.title, + key = def.key, + default = Defaults.PREF_ADAPTIVE_KEY_GEOMETRY_STRENGTH, + range = 0f..100f, + description = { it.toString() } + ) { KeyboardSwitcher.getInstance().setThemeNeedsReload() } + }, + Setting(context, Settings.PREF_ADAPTIVE_FORGET_WINDOW_MONTHS, + R.string.adaptive_forget_window, R.string.adaptive_forget_window_summary) { def -> + SliderPreference( + name = def.title, + key = def.key, + default = Defaults.PREF_ADAPTIVE_FORGET_WINDOW_MONTHS, + range = 1f..12f, + description = { "$it" } + ) { KeyboardSwitcher.getInstance().setThemeNeedsReload() } + }, + Setting(context, SettingsWithoutKey.ADAPTIVE_TYPING_STATS, + R.string.adaptive_key_geometry_stats_title, R.string.adaptive_key_geometry_stats_summary) { + Preference( + name = it.title, + description = stringResource(R.string.adaptive_key_geometry_stats_summary), + onClick = { SettingsDestination.navigateTo(SettingsDestination.AdaptiveTypingStats) } + ) + }, + Setting(context, SettingsWithoutKey.ADAPTIVE_TYPING_STATS_CONTENT, R.string.adaptive_key_geometry_stats_title) { + AdaptiveTypingStatsContent() + }, Setting(context, Settings.PREF_SHORTCUT_ROWS, R.string.shortcut_rows, R.string.shortcut_rows_summary) { SwitchPreference(it, Defaults.PREF_SHORTCUT_ROWS) }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82e563c1c..25c4339a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -395,6 +395,23 @@ Delete swipe Perform a swipe from the delete key to select and remove bigger portions of text at once + + Adaptive key geometry (experimental) + Learn where you actually tap each key and nudge gesture and tap recognition toward your style. Stored on-device only, no text is saved. + Adaptive geometry strength + Forget older typing after (months) + Learned key positions fade out over roughly this many months, so the keyboard tracks your recent typing instead of your all-time average. + Adaptive typing + Anticipate likely keys (experimental) + Make the next key the suggestions predict slightly easier to press. Averages the top suggestions and is capped so it never overrides a clear press. Applies to taps, not swipes. + Show adaptive targets on keyboard (debug) + Draw each key\'s learned landing point and a halo that grows on keys the suggestions predict, so you can watch the adaptive model work as you type. Visual only — does not change typing. + Learned typing model + See what the keyboard has learned about your typing + Per key: average offset from the key center (where you land), spread (how consistent you are), and number of samples. Higher samples and lower spread mean a stronger, more confident adjustment. Nothing here ever leaves your device. + Nothing learned yet. Turn on adaptive key geometry and type for a while, then come back. + On each key, the dot shows where you tend to land relative to the center, and the ring shows your spread. Solid dots are confident; faded dots are still learning. + Reset learned model Shortcut rows Enable temporary layout-defined shortcut rows opened by vertical swipes Top shortcut row swipe diff --git a/app/src/test/java/helium314/keyboard/latin/database/TouchModelManagerTest.kt b/app/src/test/java/helium314/keyboard/latin/database/TouchModelManagerTest.kt new file mode 100644 index 000000000..69f5e9060 --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/database/TouchModelManagerTest.kt @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.database + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Unit tests for the capped/confidence/strength policy in [TouchModelManager]. */ +class TouchModelManagerTest { + + private fun stat(meanDx: Float, meanDy: Float, count: Int) = + TouchModelDao.Stat(0x65 /* 'e' */, "qwerty", 0, meanDx, meanDy, 0f, 0f, count, 0L) + + @Test fun nullStatGivesNoOffset() { + val o = TouchModelManager.adjustedOffset(null, 100, 120, 100) + assertEquals(0f, o[0]); assertEquals(0f, o[1]) + } + + @Test fun belowConfidenceThresholdGivesNoOffset() { + // count <= MIN_CONFIDENT_SAMPLES => confidence 0 => no bias even with a large mean + val o = TouchModelManager.adjustedOffset(stat(40f, -30f, TouchModelDao.MIN_CONFIDENT_SAMPLES), 100, 120, 100) + assertEquals(0f, o[0]); assertEquals(0f, o[1]) + } + + @Test fun zeroStrengthGivesNoOffset() { + val o = TouchModelManager.adjustedOffset(stat(40f, -30f, 1000), 100, 120, 0) + assertEquals(0f, o[0]); assertEquals(0f, o[1]) + } + + @Test fun largeOffsetIsCappedToKeyFraction() { + // Full confidence + full strength, but a huge mean must be clamped to +/-25% of the key. + val o = TouchModelManager.adjustedOffset(stat(9999f, -9999f, 1000), 100, 120, 100) + assertEquals(TouchModelManager.MAX_SHIFT_FRACTION * 100, o[0], 0.001f) // +25 + assertEquals(-TouchModelManager.MAX_SHIFT_FRACTION * 120, o[1], 0.001f) // -30 + } + + @Test fun moderateOffsetPassesThroughWithinCap() { + // count=60 => confidence 1; strength 100% => full mean, well within the cap. + val o = TouchModelManager.adjustedOffset(stat(5f, -4f, TouchModelManager.FULL_CONFIDENCE_SAMPLES), 100, 120, 100) + assertEquals(5f, o[0], 0.001f) + assertEquals(-4f, o[1], 0.001f) + } + + @Test fun strengthScalesTheOffset() { + val full = TouchModelManager.adjustedOffset(stat(10f, 0f, 1000), 100, 120, 100)[0] + val half = TouchModelManager.adjustedOffset(stat(10f, 0f, 1000), 100, 120, 50)[0] + assertEquals(full / 2f, half, 0.001f) + } + + @Test fun confidenceRampIsMonotonic() { + val c20 = TouchModelManager.confidence(20) + val c40 = TouchModelManager.confidence(40) + val c60 = TouchModelManager.confidence(60) + val c100 = TouchModelManager.confidence(100) + assertEquals(0f, c20, 0.001f) + assertTrue(c40 > c20 && c40 < c60) + assertEquals(1f, c60, 0.001f) + assertEquals(1f, c100, 0.001f) + } + + @Test fun decayFactorHalvesAtHalfLife() { + assertEquals(1f, TouchModelManager.decayFactor(0L, 1000L), 0.001f) + assertEquals(0.5f, TouchModelManager.decayFactor(1000L, 1000L), 0.01f) + assertEquals(0.25f, TouchModelManager.decayFactor(2000L, 1000L), 0.01f) + assertEquals(1f, TouchModelManager.decayFactor(5000L, 0L), 0.001f) // window off => no decay + assertEquals(1f, TouchModelManager.decayFactor(-10L, 1000L), 0.001f) // future/zero elapsed => no decay + } + + @Test fun forgetWindowFadesStaleLearning() { + val hl = 1000L + // count=60 => full confidence; updatedAt=0 (from the stat helper). + val fresh = TouchModelManager.adjustedOffset(stat(10f, 0f, 60), 100, 120, 100, 0L, hl)[0] + assertEquals(10f, fresh, 0.001f) + // now = one half-life => effective count 30 => partial confidence => weaker but non-zero. + val stale = TouchModelManager.adjustedOffset(stat(10f, 0f, 60), 100, 120, 100, hl, hl)[0] + assertTrue(stale > 0f && stale < fresh, "stale must be weaker than fresh") + // now = four half-lives => effective count well below the confidence floor => fully faded. + val veryStale = TouchModelManager.adjustedOffset(stat(10f, 0f, 60), 100, 120, 100, 4L * hl, hl)[0] + assertEquals(0f, veryStale, 0.001f) + } +} diff --git a/docs/ADAPTIVE_TYPING.md b/docs/ADAPTIVE_TYPING.md new file mode 100644 index 000000000..23565e5fa --- /dev/null +++ b/docs/ADAPTIVE_TYPING.md @@ -0,0 +1,288 @@ +# Adaptive Typing — learned per-user key geometry (taps + gestures) + +> Status: **design / in progress** (opt-in feature). Tracking issue: see the +> "Adaptive learned key geometry" issue on the fork. This note is the source of +> truth for the design; update it as the implementation evolves. + +## Goal + +Make the keyboard *feel like it learns how you type* — for both **tapping** and +**gesture/glide** input — by adapting each key's effective touch geometry to where +your finger actually lands and how the current context makes some keys more likely. +This is the technique popular keyboards use ("internally resize keys"); the academic +framing is a spatial model with a context prior (Bayesian touch). + +Explicitly **not** this feature: making autocorrect pick a better *word* after the +fact. We want it to feel like "the keyboard adapted to me," not "I still miss keys +but autocorrect cleans up." (That word-ranking idea is captured separately in +`SUGGESTION_RANKING.md`.) + +## How the engine works today (why this is feasible) + +Two independent geometry systems decide what your input becomes: + +1. **Literal tap → key** — `KeyDetector.detectHitKey` (`keyboard/KeyDetector.java`) + picks the key with the smallest edge-distance (`Key.squaredDistanceToEdge`). Pure + geometry, no weighting. The exact tapped letter is committed immediately; + mistakes are fixed downstream by autocorrect. +2. **Spatial model (gestures + tap-correction)** — the native recognizer scores + candidates using per-key **sweet spots** (effective center + radius). Those sweet + spots are **computed in Java** in `ProximityInfo.createNativeProximityInfo` + (`com/android/inputmethod/keyboard/ProximityInfo.java:168-220`) from key hit-box + centers + the static per-row `TouchPositionCorrection`, then pushed across the + existing JNI (`setProximityInfoNative`). + +**Key consequence:** because the sweet spots are produced in Java and passed through +the existing JNI, we can change what a swipe resolves to **without a native (C++) +rebuild** — we just feed adjusted centers/radii. This is what lets the feature reach +gesture compose, which is a hard requirement. + +There is no dynamic or learned key resizing today. A *static* per-row +`TouchPositionCorrection` exists (sweet-spot Y offset + radius per row); its +X-correction is disabled/obsolete. + +## The model: one learned model, two consumers + +A single per-user model, keyed by **(key, layout, orientation)**, stores content-free +geometry: + +``` +TOUCH_MODEL( + key_code, layout, orientation, + mean_dx, mean_dy, -- where you land relative to the key center (EMA) + var_dx, var_dy, -- how consistent you are (for confidence + radius scaling) + count, -- samples seen (gates confidence; powers the stats page) + updated_at +) +``` + +No characters, words, or sequences are ever stored — only aggregate geometry per key. + +Two parts read it: + +- **Taps** → `KeyDetector.detectHitKey`: measure distance to each key's **learned** + center (center + mean offset) instead of the raw center → borderline taps resolve + the way *you* type. +- **Gestures** → `ProximityInfo` sweet spots: shift each key's center by its learned + offset and scale its radius by your consistency → the recognizer matches your swipe + against keys positioned where your hand actually goes. + +Same model behind both ⇒ coherent "this is how I type." + +### Layer A — dynamic context prior (tap-only, ephemeral, stores nothing) + +Each keystroke, read the in-progress word's top-N completion candidates (the engine +already produces ~18 internally; the visible 3 is only a display limit) and project +them to a *next-character* distribution: for each candidate, take the char at the +current position weighted by the candidate's score; sum per letter. Example: typed +`H`, candidates `Hello`/`Hey`/`He` → `e` dominates → **E's tap target grows slightly +for the next tap.** Recomputed live; never persisted. + +The prior is rebuilt from the suggestion strip, which is normally debounced ~100 ms (that +debounce exists because the suggestion compute *blocks the UI thread* — see +`InputLogic.performUpdateSuggestionStripSync`, which waits on `holder.get(..., 200 ms)` — so +it coalesces the expensive compute during fast typing). At that cadence the prior is still +ready before the next tap at normal speed (the functional bias works), but the *debug +overlay* visibly trails by ~one key. So **only while the debug overlay is on** we drop the +debounce to 0 in `LatinIME.UIHandler.postUpdateSuggestionStrip` (compute the prediction +immediately); since the overlay repaints the instant the prior updates, that is the safe +equivalent of "update as soon as the suggestion is made". The remaining floor is the +compute itself (~5–10 ms release, ~20 ms debug), which briefly blocks the UI by design. A +fully non-blocking async refresh would be snappier still, but the suggestion compute reads +the non-thread-safe `WordComposer` on the background thread — the blocking design exists to +avoid that race during typing — so doing it safely would need a composer snapshot (not worth +it for a debug visualization). Ordinary typing (overlay off) keeps the full debounce and is +unaffected. Lookups are case-insensitive (`AdaptiveKeyContext.weight` folds to lowercase) +so the bias also applies on the shifted keyboard, where keys report uppercase codes. + +For **gestures** there is no single "next key" to enlarge mid-stroke, so the context +prior is tap-only. The contextual-likelihood part for gestures is already handled by +the native language model (word-level). The new gesture win is Layer B (learned +geometry). + +### Layer B — learned per-user touch model (persisted, the "learning") + +- **Record (taps):** on a letter tap, compute `dx = touchX - keyCenterX`, + `dy = touchY - keyCenterY` and fold into that key's running mean/variance via an + exponential moving average (recent behavior weighted more; old data decays). + Implemented in `PointerTracker.recordAdaptiveTouchSample`. +- **Record (gestures):** a swipe also teaches the model, but only via its clean + **endpoints**: finger-down ≈ the word's first letter, finger-up ≈ its last letter. + Interior keys are skipped (corner-cutting makes them unreliable) and only fresh single + strokes count (merged/extended trails have ambiguous ends). Implemented in + `InputLogic.maybeRecordGestureEndpoints`. +- **Apply:** effective center = `center + mean_offset`; effective radius scales with + consistency (tighter for keys you nail, more forgiving for scattered keys). + +## Safety: never "I pressed W but it typed E" + +The literal tap is committed as-is, so the bias is **hard-capped** and confidence-gated: + +- Max center shift ≤ ~25% of key width/height (tunable via a strength slider). +- Radius scale bounded (e.g. 0.7–1.4×). +- A key's bias only ramps in after enough samples (`count`) and low-enough variance; + the applied magnitude scales with confidence — no sudden jumps. +- The context prior only breaks *ambiguous* taps within the cap band. **Beyond the cap + into a neighbor's territory, the neighbor always wins.** Strength = 0 ⇒ pure learned + geometry, no context flipping. + +## Privacy & security + +- Layer A persists nothing. +- Layer B persists only per-key geometry + counts — **no text content.** The only weak + signal is per-key `count` (letter-usage frequency), which never leaves the device. +- **Respect existing learning gates:** do not record while `mIncognitoModeEnabled` + (always-incognito pref, framework no-learning fields, or password fields) — the same + gate user-history learning uses. Opt-in master toggle on top. +- The settings backup already contains clipboard text + user dictionaries, so adding + content-free geometry does not widen the backup's sensitivity. We may still + quantize/round counts for extra caution. + +## Persistence, export/import, backup compatibility + +Local data lives in a single SQLite DB, `leantype.db` +(`latin/database/Database.kt`, raw `SQLiteOpenHelper`, currently VERSION 4 — clipboard + +the `TOUCH_MODEL` table, which also stores per-key width/height for fraction-of-key math). The settings backup (`settings/preferences/BackupRestorePreference.kt`, +Advanced tab) **already zips the entire `leantype.db` and restores it**, and restore +is lenient (unknown zip entries skipped; missing columns handled by schema checks in +`Database.copyFromDb` + `onUpgrade`). + +Therefore: + +- **Single export/import path (satisfied for free):** add the touch model as a **new + table in `leantype.db`** → it is automatically part of the existing Advanced-tab + backup and restored the same way. No second mechanism. +- **Move behaviors to another device:** backup → restore. +- **Delete:** a "Reset learned typing model" button clears the table. +- **Don't break existing setting files:** purely additive — bump `Database.VERSION`, + add an `onUpgrade` that `CREATE`s the table; old code reads only tables it knows, so + an old backup (no table → created empty) and a new backup on an older app (unknown + table ignored) both restore safely. The format is unversioned but tolerant by design. + +## Stats / "your learned keyboard" page (Settings) + +A visualization page (trust + the reset control live here): + +- **Heatmap**: each key with its learned offset (arrow) and a variance/confidence + ellipse — literally "what your learned keyboard looks like." +- **Per-key accuracy stats**: + - *Consistency* = how tightly you cluster on the key (low variance). + - *Correction rate* = how often a tap on the key was immediately backspaced / + autocorrected away — an **approximate** accuracy proxy (we never know true intent). + e.g. "Z corrected ~18% vs E ~2%." +- **Reset** button. + +The built page (`AdaptiveTypingStatsScreen`) renders the heatmap on a **mock keyboard** +(`MockKeyboardHeatmap`): each key shows its learned offset as a dot displaced from center +and a spread indicator, both expressed as a **fraction of the key**, so "18px on E" reads +as a visible nudge rather than an abstract number. + +## Live debug overlay ("see it in action") + +A debug toggle — **"Show adaptive targets on keyboard"** — draws the adaptive model +*directly on the live keyboard* so you can watch it work as you type. Implemented as +`AdaptiveTargetsDrawingPreview` (an `AbstractDrawingPreview`, the same overlay mechanism +as the gesture-debug points), drawn on the `DrawingPreviewPlacerView` above the keys: + +- **Learned geometry (Layer B):** for each letter key with a confident learned offset, a + faint ring marks the geometric center, an arrow points to the learned landing target, + and a filled dot marks it — the same shift `KeyDetector` biases taps toward. +- **Context prior (Layer A):** keys the current suggestions predict get a translucent + green halo whose radius grows with the prior weight. Because the prior is rebuilt + between keystrokes, the halos appear/grow/shrink **live as you type** — the keyboard + visibly "leans" toward the likely next key. + +It is purely visual (never changes detection), reads the same live model / prior / +settings the engine uses, and is gated on its pref each frame (zero cost when off). +Repaints are driven by an `AdaptiveKeyContext` change listener fired on each keystroke; +`MainKeyboardView` registers it and feeds the overlay the current keyboard + padding so +markers align with the rendered keys. The halo radius is intentionally exaggerated +relative to the engine's sub-key boost so the effect is legible. We deliberately do **not** +reflow/resize the visible keys (jarring, breaks muscle memory) — an overlay communicates +the same thing without destabilizing typing. + +## Configurability + +All adaptive controls live under one **"Adaptive typing"** section in *Gesture typing* +settings. The two halves are **independently toggleable** (either alone, both, or neither): + +- **Adaptive key geometry** (Layer B, learned offsets) — opt-in, default off. +- **Anticipate likely keys** (Layer A, context prior) — opt-in, default off. +- **Strength slider** — shared by both; shown when either toggle is on (off → gentle + tie-break → aggressive). +- **Learned typing model** stats page (heatmap + reset) — shown when learning is on. +- **Show adaptive targets on keyboard (debug)** — live visualization overlay (below); + shown when either toggle is on. +- Honors incognito / no-learning fields; learning records nothing in those contexts. + +## Implementation footprint + +- **Gestures:** `ProximityInfo.createNativeProximityInfo` (`:185-198`) — add the + learned per-key offset to `sweetSpotCenterXs/Ys` and scale `sweetSpotRadii`; when the + feature is on, generate sweet spots from key centers even for layouts lacking + `TouchPositionCorrection` data (so it doesn't depend on the layout shipping it). + Re-push on keyboard reload / model update (learning is slow → no per-keystroke native + churn). **No C++ rebuild.** +- **Taps:** `KeyDetector.detectHitKey` — distance to the learned center + the capped + context prior tie-break. +- **Store:** new table in `leantype.db` + a DAO (follow `ClipboardDao`) + a + `TouchModelManager` that computes capped effective geometry and applies the EMA + update. +- **Learning hook:** record confident-tap offsets (incognito-gated) from the input + path. +- **Settings:** 5-file pref pattern (toggle + strength), reset, stats page. + +## Caveat to validate on-device + +The default sweet spots are tuned. We must confirm the learned shifts *improve* gesture +recognition rather than destabilize it — hence confidence-gating, caps, opt-in, and the +stats page to keep it honest. Validate the gesture path explicitly (it's the priority). + +## Phased build order + +1. ✅ **Foundation:** opt-in pref (5-file) + `leantype.db` table + DAO + `TouchModelManager` + (EMA update, capped effective-geometry API). +2. ✅ **Learning + gesture injection:** record letter taps; feed learned geometry into + `ProximityInfo` sweet spots (gestures + tap-correction). +3. ✅ **Gesture-endpoint learning:** swipes teach the model via their start/end keys. +4. ✅ **Stats / "learned typing model" page** (`AdaptiveTypingStatsScreen`) + reset. +5. ✅ **Tap biasing + context prior (Layer A):** `KeyDetector` now biases the tapped key by + the learned per-key offset AND a next-key prior. The prior is built in + `AdaptiveKeyContext` from the top-5 suggestions, weighted **equally** (averaged, not + score-skewed): the next char of the in-progress word's completions, or the first char of + the next-word predictions for a fresh word. It is rebuilt between keystrokes (in + `InputLogic.setSuggestedWords`, off the tap path) and read lock-free per tap. The prior's + cap (`PRIOR_MAX_FRACTION` ≈ 18% of key) is deliberately a bit **below** the learned cap + (≈ 25%), so it nudges rather than dominates. Bias is suppressed during gestures/swipes + (`PointerTracker.isInGestureOrKeySwipe`) and only flips near-boundary taps. + + The context prior is **tap-only by design** — not because swipe suggestions are + unavailable (they do update live during a swipe), but because the prior is a *per-key* + mechanism (it enlarges the next key's tap target), and a swipe resolves a *whole word* + holistically via the recognizer, which already incorporates previous-word context through + its language model. So there is no single "next key" to enlarge mid-stroke. Making swipe + *word selection* more context-aware is the separate word-level re-ranking lever in + `SUGGESTION_RANKING.md`, not this prior. Note the resulting asymmetry: for a fresh word the + first *tap* is context-biased, but the first point of a *swipe* is not. + + The **learned geometry** (Layer B), by contrast, applies to the entire swipe including its + start — every key the stroke passes is matched against its learned-shifted sweet spot — and + a swipe's endpoints also feed the model (gesture-endpoint learning). +6. ✅ **Independent context-prior toggle + grouped settings:** Layer A (context prior) is now + its own opt-in toggle ("Anticipate likely keys"), separate from Layer B (learned geometry); + both live under one "Adaptive typing" section and share the strength slider. `KeyDetector` + gates the learned-offset bias and the prior boost independently. +7. ✅ **Heatmap stats page:** `AdaptiveTypingStatsScreen` renders the learned model on a mock + keyboard (offsets as a fraction of each key) + reset. +8. ✅ **Live debug overlay:** `AdaptiveTargetsDrawingPreview` draws learned targets + prior + halos on the real keyboard, morphing as you type (toggle: "Show adaptive targets on keyboard"). +9. ⬜ **Strength/cap tuning** + interior-key gesture learning (needs corner-cutting handling or + native alignment). + +## Open questions (tracked) + +1. Counts in backup: keep raw (slightly richer stats, faint usage signal) vs + quantize/omit. Current plan: keep (needed for the stats page), local-only + opt-in. +2. Learning scope confirmed: per layout + orientation (not per size/one-handed/floating + for v1). +3. Strength default and cap magnitude — tune on-device.