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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions .github/workflows/build-test-auto.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
name: Unit tests
# Compiles main + runs the JVM unit-test suite for the offline flavor using the `runTests`
# build type, which self-skips the network/data-dependent tests known to fail on CI
# (see `if (BuildConfig.BUILD_TYPE == "runTests") return` in XLinkTest/StringUtilsTest/InputLogicTest).
# No APK is produced; single-ABI, JVM tests only.
# (see `if (BuildConfig.BUILD_TYPE == "runTests") return` in XLinkTest/StringUtilsTest/
# InputLogicTest/KeyboardParserTest; tracked in issue #12). Hard gate: a real test failure
# fails the job. No APK is produced; single-ABI, JVM tests only.

on:
pull_request:
Expand Down Expand Up @@ -39,12 +40,6 @@ jobs:
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run unit tests
# Non-blocking for now: the suite still has ~9 failures on CI (network link tests +
# the #12 known-failing set + 3 new MiscTest.isWideScreen from the v3.8.5 merge).
# This RUNS the full suite and uploads the report on every PR (closing the "CI only
# compiles" gap); flip continue-on-error off once those are triaged (#12) to make it a
# hard gate.
continue-on-error: true
run: ./gradlew :app:testOfflineRunTestsUnitTest

- name: Archive test reports
Expand Down
7 changes: 5 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ android {
applicationId = "com.asafmah.leantypedual"
minSdk = 21
targetSdk = 35
versionCode = 3850
versionName = "3.8.5"
versionCode = 3860
versionName = "3.8.6"

proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")

Expand Down Expand Up @@ -250,6 +250,9 @@ dependencies {
testImplementation("androidx.test.ext:junit:1.1.5")
testImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
// runTests is the CI variant; it needs the Compose test manifest (ComponentActivity) too,
// otherwise Compose UI tests (e.g. MiscTest.isWideScreen) fail to resolve the host activity.
"runTestsImplementation"("androidx.compose.ui:ui-test-manifest")
}

// Disable baseline/ART profile tasks to guarantee deterministic reproducible builds (except for standardOptimised)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -619,12 +619,13 @@ protected void onDetachedFromWindow() {
@Nullable
public PopupKeysPanel showPopupKeysKeyboard(@NonNull final Key key,
@NonNull final PointerTracker tracker) {
return showPopupKeysKeyboard(key, tracker, false);
return showPopupKeysKeyboard(key, tracker, false, PopupKeysKeyboardView.NO_ROW_ALIGN);
}

@Nullable
private PopupKeysPanel showPopupKeysKeyboard(@NonNull final Key key,
@NonNull final PointerTracker tracker, final boolean belowSourceKey) {
@NonNull final PointerTracker tracker, final boolean belowSourceKey,
final int rowAlignedLeftX) {
final PopupKeySpec[] popupKeys = key.getPopupKeys();
if (popupKeys == null) {
return null;
Expand Down Expand Up @@ -675,8 +676,10 @@ private PopupKeysPanel showPopupKeysKeyboard(@NonNull final Key key,
// {@code mPreviewVisibleOffset} has been set appropriately in
// {@link KeyboardView#showKeyPreview(PointerTracker)}.
final int pointY = belowSourceKey
? key.getY() + key.getHeight() + container.getMeasuredHeight()
? key.getY() + key.getHeight()
: key.getY() + mKeyPreviewDrawParams.getVisibleOffset();
popupKeysKeyboardView.setShowBelowAnchor(belowSourceKey);
popupKeysKeyboardView.setRowAlignedLeftX(rowAlignedLeftX);
popupKeysKeyboardView.showPopupKeysPanel(this, this, pointX, pointY, mKeyboardActionListener);
return popupKeysKeyboardView;
}
Expand All @@ -695,7 +698,18 @@ public PopupKeysPanel showShortcutRowKeyboard(@NonNull final Key key,
if (popupParentKey == null) {
return null;
}
return showPopupKeysKeyboard(popupParentKey, tracker, belowSourceKey);
// Align the shortcut popup's icons to the source letter row so the swiped key maps to the
// icon directly above it, instead of re-anchoring on the swiped key (which the irregular
// shift/backspace keys at the row edges throw off). Row left = leftmost normal key in the row.
int rowLeftX = Integer.MAX_VALUE;
for (final Key rowKey : keyboard.getSortedKeys()) {
if (rowKey.getY() == key.getY() && rowKey.getBackgroundType() == Key.BACKGROUND_TYPE_NORMAL
&& !rowKey.isModifier() && !rowKey.isSpacer()) {
rowLeftX = Math.min(rowLeftX, rowKey.getX());
}
}
if (rowLeftX == Integer.MAX_VALUE) rowLeftX = key.getX();
return showPopupKeysKeyboard(popupParentKey, tracker, belowSourceKey, rowLeftX);
}

public boolean isInDraggingFinger() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ public class PopupKeysKeyboardView extends KeyboardView implements PopupKeysPane
private int mOriginX;
private int mOriginY;
private Key mCurrentKey;
// When true, the panel is anchored below the source key (its top aligned to pointY)
// instead of the default above-key placement (its bottom aligned to pointY).
private boolean mShowBelowAnchor;
// Shortcut-row popup: when set (not NO_ROW_ALIGN), the panel's visible content left edge is
// pinned to this x (the source letter row's left edge) so the icons tile across the row and the
// swiped key maps to the icon directly above it — instead of re-anchoring on the swiped key.
public static final int NO_ROW_ALIGN = Integer.MIN_VALUE;
private int mRowAlignedLeftX = NO_ROW_ALIGN;

private int mActivePointerId;

Expand Down Expand Up @@ -137,6 +145,15 @@ public void showPopupKeysPanel(final View parentView, final Controller controlle
mEmojiViewCallback = emojiViewCallback;
showPopupKeysPanelInternal(parentView, controller, pointX, pointY);
}
// Must be called before showPopupKeysPanel(...) for a given show; resets per show.
public void setShowBelowAnchor(final boolean below) {
mShowBelowAnchor = below;
}

// Must be called before showPopupKeysPanel(...) for a given show; NO_ROW_ALIGN resets it.
public void setRowAlignedLeftX(final int rowLeftX) {
mRowAlignedLeftX = rowLeftX;
}

@SuppressLint("RtlHardcoded") // a key on the left is on the left, independent of layout direction
private void showPopupKeysPanelInternal(final View parentView, final Controller controller,
Expand All @@ -145,9 +162,19 @@ private void showPopupKeysPanelInternal(final View parentView, final Controller
final View container = getContainerView();
// The coordinates of panel's left-top corner in parentView's coordinate system.
// We need to consider background drawable paddings.
final int x = pointX - getDefaultCoordX() - container.getPaddingLeft() - getPaddingLeft();
final int y = pointY - container.getMeasuredHeight() + container.getPaddingBottom()
+ getPaddingBottom();
// Default: anchor the panel so its default column sits under pointX (centered on the key).
// Row-aligned (shortcut-row popup): pin the visible content left edge to the source row's
// left edge so the icons tile across the row and the swiped key maps to the icon above it.
final int x = (mRowAlignedLeftX != NO_ROW_ALIGN)
? mRowAlignedLeftX - container.getPaddingLeft() - getPaddingLeft()
: pointX - getDefaultCoordX() - container.getPaddingLeft() - getPaddingLeft();
// Above-anchor (default): panel bottom aligned to pointY (popup sits above the key).
// Below-anchor: panel top aligned to pointY (popup sits below the key), mirroring the
// padding compensation so the visible content edge lands on pointY either way.
final int y = mShowBelowAnchor
? pointY - container.getPaddingTop() - getPaddingTop()
: pointY - container.getMeasuredHeight() + container.getPaddingBottom()
+ getPaddingBottom();

parentView.getLocationInWindow(mCoordinates);
final int containerY = y + CoordinateUtils.y(mCoordinates);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ void resetDictionaries(
/** removes the word from all editable dictionaries, and adds it to a blacklist in case it's in a read-only dictionary */
void removeWord(String word);

/** promotes the word to the user (personal) dictionary and removes it from the blacklist */
void addToUserDictionary(String word);

/** permanently blocks the word: removes it from editable dictionaries and adds it to the blacklist */
void blockWord(String word);

void closeDictionaries();

/** main dictionaries are loaded asynchronously after resetDictionaries */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,26 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator {
}
}

override fun addToUserDictionary(word: String) {
if (word.isEmpty()) return
val group = currentlyPreferredDictionaryGroup
// Resolve the user dictionary first: if it isn't loaded we cannot add, and we must NOT
// un-blacklist the word in that case (that would leave it neither blocked nor added).
val userDict = group.getSubDict(Dictionary.TYPE_USER) ?: return
group.removeFromBlacklist(word) // promoting a word un-blocks it
scope.launch {
// adding can throw IllegalArgumentException on some devices, see addToPersonalDictionaryIfInvalidButInHistory
runCatching { UserDictionary.Words.addWord(userDict.mContext, word, 250, null, group.locale) }
}
}

override fun blockWord(word: String) {
// A permanent block is exactly removeWord: group.removeWord already blacklists the word in
// every group (including words that lived only in user history), so it cannot be re-learned.
if (word.isEmpty()) return
removeWord(word)
}

override fun clearUserHistoryDictionary(context: Context) {
for (dictionaryGroup in dictionaryGroups) {
dictionaryGroup.getSubDict(Dictionary.TYPE_USER_HISTORY)?.clear()
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/helium314/keyboard/latin/LatinIME.java
Original file line number Diff line number Diff line change
Expand Up @@ -1783,6 +1783,16 @@ public void removeSuggestion(final String word) {
mDictionaryFacilitator.removeWord(word);
}

@Override
public void addToDictionary(final String word) {
mDictionaryFacilitator.addToUserDictionary(word);
}

@Override
public void blockWord(final String word) {
mDictionaryFacilitator.blockWord(word);
}

@Override
public void removeExternalSuggestions() {
setNeutralSuggestionStrip();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ class SingleDictionaryFacilitator(private val dict: Dictionary) : DictionaryFaci

override fun removeWord(word: String) {}

override fun addToUserDictionary(word: String) {}

override fun blockWord(word: String) {}

override fun clearUserHistoryDictionary(context: Context) {}

override fun localesAndConfidences(): String? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,15 @@ public void onStartBatchInput(final SettingsValues settingsValues,
// Combining mode: snapshot before cancel; the gesture will re-arm the timer on completion.
final boolean wasInCombiningMode = mInCombiningMode;
cancelCombiningMode();
// Two-thumb typing: defensively drop any stale merged-trail extend-base before this
// gesture's extend decision. The base is meant to live for exactly one gesture (set in
// the extend branch below, cleared in onUpdateTailBatchInputCompleted), but an abnormal
// prior gesture end — a cancel, or an empty-top recognition that early-returns before
// that clear — can leave it armed. A fresh gesture would then merge with the ghost
// trail; most visibly at the start of a text box, where no composing word exists to
// re-set it. Clearing here guarantees every gesture begins from a clean base; the
// extend branch re-arms it from the real composing word when appropriate.
mWordComposer.setExtendBatchInputBase(null);
mConnection.beginBatchEdit();
// Two-thumb typing (#1.1 + combining-mode): two ways the gesture can EXTEND an existing
// composing word instead of replacing it:
Expand Down Expand Up @@ -895,6 +904,10 @@ public void onCancelBatchInput(final LatinIME.UIHandler handler) {
// Drop any seed codepoint stashed by PointerTracker so the next gesture doesn't
// strip its first letter against a stale seed.
helium314.keyboard.keyboard.PointerTracker.consumeGestureSeedCodepoint();
// Two-thumb typing: a cancelled gesture never reaches onUpdateTailBatchInputCompleted,
// which is the only routine site that clears the merged-trail extend-base. Drop it here
// so this cancelled gesture's trail can't leak into the next one.
mWordComposer.setExtendBatchInputBase(null);
// Tap-promotion-extend was a per-gesture decision; clear on cancel so the NEXT
// gesture re-evaluates against current timing.
mGestureExtendsByTapPromotion = false;
Expand Down Expand Up @@ -2360,6 +2373,12 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu
// stroke. The whole-word and batch delete branches below call mWordComposer.reset()
// directly rather than resetComposingState(), so clear here to cover every path.
mLiveStroke.reset();
// Two-thumb typing: a backspace invalidates the merged-trail extend-base for the same
// reason it invalidates mLiveStroke above — re-doing the word must start from a clean
// stroke, not merge with the geometry we just partially deleted. This single clear
// covers all three backspace modes (character / fragment / whole-word), since it runs
// before any of their branches.
mWordComposer.setExtendBatchInputBase(null);
// Typing-insight overlay: a backspace edits/clears the gesture word, so its trail is now
// stale. Drop it so it doesn't linger.
final MainKeyboardView backspaceKv = KeyboardSwitcher.getInstance().getMainKeyboardView();
Expand Down Expand Up @@ -3547,6 +3566,9 @@ private void resetComposingState(final boolean alsoResetLastComposedWord) {
mCombiningWordHasGestureFragment = false;
// Live-converge (#1.7): the word is gone, so its accumulated stroke is stale.
mLiveStroke.reset();
// Two-thumb typing: likewise drop the merged-trail extend-base. This path also covers
// the delete slider (finishInput -> resetComposingState) and cursor-move resets.
mWordComposer.setExtendBatchInputBase(null);
// Combining mode is keyed on a composing word existing; if we're wiping it, the
// pending timer would commit nothing useful, so cancel.
cancelCombiningMode();
Expand Down Expand Up @@ -4177,6 +4199,8 @@ private void commitChosenWord(final SettingsValues settingsValues, final String
mCombiningWordHasGestureFragment = false;
// Live-converge (#1.7): word committed — its accumulated stroke no longer applies.
mLiveStroke.reset();
// Two-thumb typing: the merged-trail extend-base belongs to the just-committed word too.
mWordComposer.setExtendBatchInputBase(null);
if (DebugFlags.DEBUG_ENABLED) {
long runTimeMillis = System.currentTimeMillis() - startTimeMillis;
Log.d(TAG, "commitChosenWord() : " + runTimeMillis + " ms to run "
Expand Down Expand Up @@ -4339,6 +4363,8 @@ private void setComposingTextInternalWithBackgroundColor(final CharSequence newC
// Live-converge (#1.7): composing was force-cancelled due to a desync — the stored
// stroke no longer matches anything on screen, so drop it.
mLiveStroke.reset();
// Two-thumb typing: the merged-trail extend-base is equally stale after a desync.
mWordComposer.setExtendBatchInputBase(null);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ object Defaults {
const val PREF_CLIPBOARD_FOLD_PINNED = false
const val PREF_CLEAR_CLIPBOARD_ICON = "bin"
const val PREF_ADD_TO_PERSONAL_DICTIONARY = true
const val PREF_FLAG_UNKNOWN_WORDS = true
@JvmField
val PREF_NAVBAR_COLOR = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
const val PREF_NARROW_KEY_GAPS = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
public static final String PREF_CLEAR_CLIPBOARD_ICON = "clear_clipboard_icon";

public static final String PREF_ADD_TO_PERSONAL_DICTIONARY = "add_to_personal_dictionary";
public static final String PREF_FLAG_UNKNOWN_WORDS = "flag_unknown_words";
public static final String PREF_NAVBAR_COLOR = "navbar_color";
public static final String PREF_NARROW_KEY_GAPS = "narrow_key_gaps";
public static final String PREF_NARROW_KEY_GAPS_LEVEL = "narrow_key_gaps_level";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ public class SettingsValues {
public final boolean mQuickPinToolbarKeys;
public final int mScreenMetrics;
public final boolean mAddToPersonalDictionary;
public final boolean mFlagUnknownWords;
public final boolean mUseContactsDictionary;
public final boolean mUseAppsDictionary;
public final boolean mCustomNavBarColor;
Expand Down Expand Up @@ -468,6 +469,8 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina
mPopupKeyLabelSources = SubtypeUtilsKt.getPopupKeyLabelSources(selectedSubtype, prefs);
mAddToPersonalDictionary = prefs.getBoolean(Settings.PREF_ADD_TO_PERSONAL_DICTIONARY,
Defaults.PREF_ADD_TO_PERSONAL_DICTIONARY);
mFlagUnknownWords = prefs.getBoolean(Settings.PREF_FLAG_UNKNOWN_WORDS,
Defaults.PREF_FLAG_UNKNOWN_WORDS);
mUseContactsDictionary = SettingsValues.readUseContactsEnabled(prefs, context);
mUseAppsDictionary = prefs.getBoolean(Settings.PREF_USE_APPS, Defaults.PREF_USE_APPS);
mCustomNavBarColor = prefs.getBoolean(Settings.PREF_NAVBAR_COLOR, Defaults.PREF_NAVBAR_COLOR);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import android.text.style.CharacterStyle;
import android.text.style.StyleSpan;
import android.text.style.UnderlineSpan;
import helium314.keyboard.latin.dictionary.Dictionary;
import android.util.AttributeSet;
import android.view.Gravity;
import android.view.View;
Expand Down Expand Up @@ -523,6 +524,21 @@ private int setupWordViewsAndReturnStartIndexOfMoreSuggestions(
wordView.setText(getStyledSuggestedWord(suggestedWords, indexInSuggestedWords));
wordView.setTextColor(getSuggestionTextColor(suggestedWords, indexInSuggestedWords));

// Flag uncurated words with an underline when the pref is enabled
if (Settings.getValues().mFlagUnknownWords) {
final SuggestedWordInfo info = suggestedWords.getInfo(indexInSuggestedWords);
if (info != null && info.mSourceDict != null
&& (info.mSourceDict.mDictType.equals(Dictionary.TYPE_USER_HISTORY)
|| info.mSourceDict.mDictType.equals(Dictionary.TYPE_USER_TYPED))) {
final CharSequence current = wordView.getText();
if (!StringUtilsKt.isEmoji(current)) {
final SpannableString flagged = new SpannableString(current != null ? current : "");
flagged.setSpan(UNDERLINE_SPAN, 0, flagged.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
wordView.setText(flagged);
}
}
}

if (emojiTypeface != null && StringUtilsKt.isEmoji(wordView.getText()))
wordView.setTypeface(emojiTypeface);
else
Expand Down
Loading
Loading