From 1d82631eaf5458a30a236a75cdf73e79613061d1 Mon Sep 17 00:00:00 2001 From: AsafMah Date: Thu, 11 Jun 2026 09:02:41 +0300 Subject: [PATCH 1/6] feat(spacing): per-keystroke spacing-policy signals (#24 foundation) (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the spacing-policy epic (#14), per docs/SPACING_POLICY.md. Computes the two free signals from the existing suggestion results every keystroke (zero extra native calls), in a pure static helper for testability: - complete = typed word is a real dictionary word (mTypedWordValid AND source != user_typed) - prefixRichScore = fraction of candidates that are KIND_COMPLETION, [0..1] computeSpacingSignals(SuggestedWords) -> SpacingSignals; wired into setSuggestedWords to store mSpacingComplete / mSpacingPrefixRichScore. No behavior change yet — the signal-driven graceMs + two-gate Assisted tier (and the A11 insight readout) consume these next. SpacingSignalsTest: 6 cases (complete for real-dict / not for user-typed / not for invalid; prefix-rich fraction; empty). All green. --- .../keyboard/latin/inputlogic/InputLogic.java | 45 ++++++++++++ .../latin/inputlogic/SpacingSignalsTest.kt | 69 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 app/src/test/java/helium314/keyboard/latin/inputlogic/SpacingSignalsTest.kt 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 4ac354a4d..834cc1561 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -95,6 +95,11 @@ public final class InputLogic { private int mSpaceState; // Never null private SuggestedWords mSuggestedWords = SuggestedWords.getEmptyInstance(); + // #14 spacing-policy signals — recomputed every keystroke from the suggestion results at zero + // extra native cost (see computeSpacingSignals / setSuggestedWords). Consumed by the upcoming + // signal-driven grace + two-gate Assisted-tier logic. + private boolean mSpacingComplete; // typed word is a real dictionary word + private float mSpacingPrefixRichScore; // fraction of candidates that are completions [0..1] private final Suggest mSuggest; private final DictionaryFacilitator mDictionaryFacilitator; private SingleDictionaryFacilitator mEmojiDictionaryFacilitator; @@ -1399,6 +1404,9 @@ public void setSuggestedWords(final SuggestedWords suggestedWords) { mWordComposer.setAutoCorrection(suggestedWordInfo); } mSuggestedWords = suggestedWords; + final SpacingSignals spacingSignals = computeSpacingSignals(suggestedWords); + mSpacingComplete = spacingSignals.complete; + mSpacingPrefixRichScore = spacingSignals.prefixRichScore; final boolean newAutoCorrectionIndicator = suggestedWords.mWillAutoCorrect; // Put a blue underline to a word in TextView which will be auto-corrected. @@ -1416,6 +1424,43 @@ public void setSuggestedWords(final SuggestedWords suggestedWords) { } } + /** + * #14 spacing-policy signals derived from the current suggestion results, computed every + * keystroke at zero extra native cost. + * + * Static + pure so it can be unit-tested without a live InputLogic. + */ + static final class SpacingSignals { + final boolean complete; + final float prefixRichScore; + SpacingSignals(final boolean complete, final float prefixRichScore) { + this.complete = complete; + this.prefixRichScore = prefixRichScore; + } + } + + static SpacingSignals computeSpacingSignals(final SuggestedWords suggestedWords) { + final int n = suggestedWords.size(); + if (n == 0) return new SpacingSignals(false, 0f); + final SuggestedWordInfo typed = suggestedWords.mTypedWordInfo; + final boolean complete = suggestedWords.mTypedWordValid + && typed != null && typed.mSourceDict != null + && !Dictionary.TYPE_USER_TYPED.equals(typed.mSourceDict.mDictType); + int completions = 0; + for (int i = 0; i < n; i++) { + if (suggestedWords.getInfo(i).getKind() == SuggestedWordInfo.KIND_COMPLETION) { + completions++; + } + } + return new SpacingSignals(complete, (float) completions / n); + } + /** * Handle a consumed event. *

diff --git a/app/src/test/java/helium314/keyboard/latin/inputlogic/SpacingSignalsTest.kt b/app/src/test/java/helium314/keyboard/latin/inputlogic/SpacingSignalsTest.kt new file mode 100644 index 000000000..e3296d37d --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/inputlogic/SpacingSignalsTest.kt @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.inputlogic + +import helium314.keyboard.latin.SuggestedWords +import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo +import helium314.keyboard.latin.dictionary.Dictionary +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [InputLogic.computeSpacingSignals] (#14 spacing policy): the free per-keystroke + * `complete` + `prefixRichScore` signals derived from the suggestion results. Pure logic. + */ +class SpacingSignalsTest { + + // mDictType != "user_typed" -> counts as a "real" dictionary source for `complete`. + private val realDict: Dictionary = Dictionary.DICTIONARY_APPLICATION_DEFINED + private val userTyped: Dictionary = Dictionary.DICTIONARY_USER_TYPED + + private fun info(word: String, kind: Int, dict: Dictionary): SuggestedWordInfo = + SuggestedWordInfo(word, "", 0, kind, dict, + SuggestedWordInfo.NOT_AN_INDEX, SuggestedWordInfo.NOT_A_CONFIDENCE) + + private fun words(typed: SuggestedWordInfo?, typedValid: Boolean, + list: List): SuggestedWords = + SuggestedWords(ArrayList(list), null, typed, typedValid, false, false, + SuggestedWords.INPUT_STYLE_TYPING, SuggestedWords.NOT_A_SEQUENCE_NUMBER) + + @Test fun `empty suggestions yield no signals`() { + val s = InputLogic.computeSpacingSignals(SuggestedWords.getEmptyInstance()) + assertFalse(s.complete) + assertEquals(0f, s.prefixRichScore, 0f) + } + + @Test fun `valid typed word from a real dictionary is complete`() { + val typed = info("the", SuggestedWordInfo.KIND_TYPED, realDict) + assertTrue(InputLogic.computeSpacingSignals(words(typed, true, listOf(typed))).complete) + } + + @Test fun `valid typed word from the user-typed source is NOT complete`() { + val typed = info("xyzzy", SuggestedWordInfo.KIND_TYPED, userTyped) + assertFalse(InputLogic.computeSpacingSignals(words(typed, true, listOf(typed))).complete) + } + + @Test fun `invalid typed word is not complete`() { + val typed = info("teh", SuggestedWordInfo.KIND_TYPED, realDict) + assertFalse(InputLogic.computeSpacingSignals(words(typed, false, listOf(typed))).complete) + } + + @Test fun `prefix-rich score is the fraction of completions`() { + val typed = info("ba", SuggestedWordInfo.KIND_TYPED, realDict) + val list = listOf( + typed, + info("bad", SuggestedWordInfo.KIND_COMPLETION, realDict), + info("bat", SuggestedWordInfo.KIND_COMPLETION, realDict), + info("ball", SuggestedWordInfo.KIND_COMPLETION, realDict), + ) + // 3 completions out of 4 candidates. + assertEquals(0.75f, InputLogic.computeSpacingSignals(words(typed, false, list)).prefixRichScore, 1e-6f) + } + + @Test fun `no completions yields zero prefix-rich score`() { + val typed = info("the", SuggestedWordInfo.KIND_TYPED, realDict) + assertEquals(0f, + InputLogic.computeSpacingSignals(words(typed, true, listOf(typed))).prefixRichScore, 0f) + } +} From 65846f5198519576b4dc76f65ceceed4a5d3b4f2 Mon Sep 17 00:00:00 2001 From: AsafMah Date: Fri, 12 Jun 2026 19:36:27 +0300 Subject: [PATCH 2/6] test(native): gesture replay harness + fixture loader (#78 deliverable 2) (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a native gesture-replay harness that can consume TraceRecorder JSON fixtures and run inside the existing host CMake / ctest workflow. New files: tests/replay/trace_fixture.h Header-only C++17 fixture loader (parseFixture / loadFixture). Parses the TraceRecorder schema (version 1) into plain structs with xCoordinates() / yCoordinates() / times() / pointerIds() accessors ready to forward to Suggest::getSuggestions once blockers are lifted. tests/replay/gesture_replay_test.cpp TraceFixtureParserTest (9 enabled tests) — cover JSON parsing, accessor arrays, monotonic timestamps, coordinate bounds, escape handling, and empty-pointer edge cases. DISABLED_GestureReplayTest.ReplayHelloQwerty — compile-checked scaffold documenting the exact API seam and two concrete blockers. tests/replay/fixtures/hello_qwerty.json Seed fixture: 14-sample "hello" trace on a 1080×310 QWERTY keyboard (en-US). Consumed by TraceFixtureParserTest.LoadsHelloQwertyFromFile. CMakeLists.txt: Add target_compile_definitions(FIXTURE_DIR=...) so the file-path test resolves the fixture directory at compile time without network access. Blockers documented in gesture_replay_test.cpp (DISABLED_ comment block): 1. ProximityInfo requires a live JNIEnv* — calls env->GetArrayLength() unconditionally in its constructor; no non-JNI overload exists. Fix: add a raw-pointer constructor or a fake-JNIEnv shim. 2. Dictionary requires a compiled binary .dict asset not in the tree. Fix: bundle a small en_US dict via CMake FetchContent, or generate one programmatically using the existing v4 writer classes. ctest result: 76/76 pass, 1 disabled (GestureReplayTest.ReplayHelloQwerty) --- app/src/main/jni/CMakeLists.txt | 4 + .../tests/replay/fixtures/hello_qwerty.json | 27 ++ .../jni/tests/replay/gesture_replay_test.cpp | 307 ++++++++++++++++++ app/src/main/jni/tests/replay/trace_fixture.h | 244 ++++++++++++++ 4 files changed, 582 insertions(+) create mode 100644 app/src/main/jni/tests/replay/fixtures/hello_qwerty.json create mode 100644 app/src/main/jni/tests/replay/gesture_replay_test.cpp create mode 100644 app/src/main/jni/tests/replay/trace_fixture.h diff --git a/app/src/main/jni/CMakeLists.txt b/app/src/main/jni/CMakeLists.txt index 5068fbf6d..dd6e6e64f 100644 --- a/app/src/main/jni/CMakeLists.txt +++ b/app/src/main/jni/CMakeLists.txt @@ -55,6 +55,10 @@ target_include_directories(latinime_host_unittests PRIVATE ${SRC_DIR} ${TEST_DIR target_compile_options(latinime_host_unittests PRIVATE -include ${CMAKE_CURRENT_SOURCE_DIR}/host_test_compat.h -Wno-unused-parameter -Wno-unused-function) +# Expose the on-disk fixture directory to the replay tests so they can load +# JSON fixtures by path (TraceFixtureParserTest.LoadsHelloQwertyFromFile). +target_compile_definitions(latinime_host_unittests PRIVATE + FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/replay/fixtures") target_link_libraries(latinime_host_unittests PRIVATE gtest gtest_main) enable_testing() diff --git a/app/src/main/jni/tests/replay/fixtures/hello_qwerty.json b/app/src/main/jni/tests/replay/fixtures/hello_qwerty.json new file mode 100644 index 000000000..8bdfa0516 --- /dev/null +++ b/app/src/main/jni/tests/replay/fixtures/hello_qwerty.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "createdAt": 1720000000000, + "keyboard": { + "width": 1080, + "height": 310, + "mainLayout": "qwerty", + "locale": "en-US" + }, + "committedWord": "hello", + "pointers": [ + {"id": 0, "x": 648, "y": 185, "t": 0}, + {"id": 0, "x": 590, "y": 168, "t": 48}, + {"id": 0, "x": 450, "y": 135, "t": 98}, + {"id": 0, "x": 340, "y": 110, "t": 148}, + {"id": 0, "x": 270, "y": 100, "t": 200}, + {"id": 0, "x": 370, "y": 120, "t": 258}, + {"id": 0, "x": 540, "y": 158, "t": 308}, + {"id": 0, "x": 660, "y": 185, "t": 358}, + {"id": 0, "x": 756, "y": 185, "t": 408}, + {"id": 0, "x": 810, "y": 185, "t": 450}, + {"id": 0, "x": 864, "y": 185, "t": 500}, + {"id": 0, "x": 864, "y": 184, "t": 552}, + {"id": 0, "x": 891, "y": 143, "t": 600}, + {"id": 0, "x": 918, "y": 100, "t": 648} + ] +} diff --git a/app/src/main/jni/tests/replay/gesture_replay_test.cpp b/app/src/main/jni/tests/replay/gesture_replay_test.cpp new file mode 100644 index 000000000..5c9944372 --- /dev/null +++ b/app/src/main/jni/tests/replay/gesture_replay_test.cpp @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Native gesture-replay harness — issue #78, deliverable 2. +// +// ============================================================================= +// ENABLED tests (TraceFixtureParserTest.*) +// Validate the JSON fixture loader end-to-end: parse an embedded literal and, +// when FIXTURE_DIR is defined, a file from the on-disk fixture directory. +// These always pass in ctest without any runtime assets. +// +// DISABLED tests (DISABLED_GestureReplayTest.*) +// Compile-checked stubs that would feed a loaded trace through the latinime +// gesture recognizer. Disabled because two blockers must be resolved first: +// +// BLOCKER 1 — JNI runtime required by ProximityInfo: +// ProximityInfo::ProximityInfo(JNIEnv*, ...) calls env->GetArrayLength() and +// env->GetIntArrayRegion() unconditionally on the very first line of the +// constructor body (proximity_info.cpp:76). There is no non-JNI constructor +// overload. A live JVM (JavaVM + JNI_CreateJavaVM, or an Android runtime) +// must be available to create a ProximityInfo with real keyboard geometry. +// +// BLOCKER 2 — binary dictionary asset required: +// Dictionary wraps a DictionaryStructureWithBufferPolicy that is loaded from +// a compiled binary .dict file (the Android asset pipeline provides these at +// runtime; they are not part of the source tree and are not downloaded by the +// CMake build). Without a valid dict the gesture scorer has nothing to search. +// +// Next concrete steps to get real replay assertions: +// a) Either create a minimal fake JNIEnv shim (filling JNINativeInterface_ +// function pointers) so ProximityInfo can be constructed on the host, OR +// add a non-JNI constructor that accepts raw int*/float* arrays directly. +// b) Download / bundle a small compiled English .dict (e.g. the AOSP +// "en_US" dict, ~1 MB) as a test asset via CMake FetchContent, OR +// generate a tiny programmatic dict using the existing v4 writer classes. +// c) Wire ProximityInfo + Dictionary + DicTraverseSession together and +// uncomment the assertion in DISABLED_GestureReplayTest.ReplayHelloQwerty. +// ============================================================================= + +#include + +#include "replay/trace_fixture.h" + +namespace latinime { +namespace replay { +namespace { + +// ---- Embedded fixture (TraceRecorder schema v1, word "hello") --------------- +// +// Recorded on a 1080×310 px QWERTY keyboard (en-US). +// Key-centre estimates (px): +// h ≈ (648, 185) e ≈ (270, 100) l ≈ (864, 185) o ≈ (918, 100) +// +static constexpr const char kHelloQwertyJson[] = + R"json({"version":1,"createdAt":1720000000000,)json" + R"json("keyboard":{"width":1080,"height":310,)json" + R"json("mainLayout":"qwerty","locale":"en-US"},)json" + R"json("committedWord":"hello",)json" + R"json("pointers":[)json" + R"json({"id":0,"x":648,"y":185,"t":0},)json" + R"json({"id":0,"x":590,"y":168,"t":48},)json" + R"json({"id":0,"x":450,"y":135,"t":98},)json" + R"json({"id":0,"x":340,"y":110,"t":148},)json" + R"json({"id":0,"x":270,"y":100,"t":200},)json" + R"json({"id":0,"x":370,"y":120,"t":258},)json" + R"json({"id":0,"x":540,"y":158,"t":308},)json" + R"json({"id":0,"x":660,"y":185,"t":358},)json" + R"json({"id":0,"x":756,"y":185,"t":408},)json" + R"json({"id":0,"x":810,"y":185,"t":450},)json" + R"json({"id":0,"x":864,"y":185,"t":500},)json" + R"json({"id":0,"x":864,"y":184,"t":552},)json" + R"json({"id":0,"x":891,"y":143,"t":600},)json" + R"json({"id":0,"x":918,"y":100,"t":648})json" + R"json(]})json"; + +// ============================================================================= +// TraceFixtureParserTest — enabled, runs in ctest +// ============================================================================= + +TEST(TraceFixtureParserTest, ParsesVersionAndMetadata) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + EXPECT_EQ(1, fix.version); + EXPECT_EQ(1720000000000LL, fix.createdAt); + EXPECT_EQ("hello", fix.committedWord); +} + +TEST(TraceFixtureParserTest, ParsesKeyboardGeometry) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + EXPECT_EQ(1080, fix.keyboard.width); + EXPECT_EQ(310, fix.keyboard.height); + EXPECT_EQ("qwerty", fix.keyboard.mainLayout); + EXPECT_EQ("en-US", fix.keyboard.locale); +} + +TEST(TraceFixtureParserTest, ParsesPointerCount) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + EXPECT_EQ(14, fix.inputSize()); + ASSERT_EQ(14u, fix.pointers.size()); +} + +TEST(TraceFixtureParserTest, ParsesFirstAndLastPointerSample) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + + // First sample — starts over 'h' + EXPECT_EQ(0, fix.pointers.front().id); + EXPECT_EQ(648, fix.pointers.front().x); + EXPECT_EQ(185, fix.pointers.front().y); + EXPECT_EQ(0, fix.pointers.front().t); + + // Last sample — ends over 'o' + EXPECT_EQ(0, fix.pointers.back().id); + EXPECT_EQ(918, fix.pointers.back().x); + EXPECT_EQ(100, fix.pointers.back().y); + EXPECT_EQ(648, fix.pointers.back().t); +} + +TEST(TraceFixtureParserTest, AccessorArraysMatchPointers) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + const auto xs = fix.xCoordinates(); + const auto ys = fix.yCoordinates(); + const auto ts = fix.times(); + const auto ids = fix.pointerIds(); + + ASSERT_EQ(fix.pointers.size(), xs.size()); + for (std::size_t i = 0; i < fix.pointers.size(); ++i) { + EXPECT_EQ(fix.pointers[i].x, xs[i]) << "xs mismatch at " << i; + EXPECT_EQ(fix.pointers[i].y, ys[i]) << "ys mismatch at " << i; + EXPECT_EQ(fix.pointers[i].t, ts[i]) << "ts mismatch at " << i; + EXPECT_EQ(fix.pointers[i].id, ids[i]) << "ids mismatch at " << i; + } +} + +TEST(TraceFixtureParserTest, TimestampsAreMonotonicallyNonDecreasing) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + for (std::size_t i = 1; i < fix.pointers.size(); ++i) { + EXPECT_GE(fix.pointers[i].t, fix.pointers[i - 1].t) + << "timestamp regression at index " << i; + } +} + +TEST(TraceFixtureParserTest, AllPointersWithinKeyboardBounds) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + for (std::size_t i = 0; i < fix.pointers.size(); ++i) { + EXPECT_GE(fix.pointers[i].x, 0) << "x < 0 at " << i; + EXPECT_LE(fix.pointers[i].x, fix.keyboard.width) << "x > width at " << i; + EXPECT_GE(fix.pointers[i].y, 0) << "y < 0 at " << i; + EXPECT_LE(fix.pointers[i].y, fix.keyboard.height) << "y > height at " << i; + } +} + +TEST(TraceFixtureParserTest, ParsesJsonEscapedString) { + // Verify the parser handles escaped double-quotes and backslashes in strings. + const std::string json = + R"({"version":1,"createdAt":0,)" + R"("keyboard":{"width":100,"height":100,"mainLayout":"a\"b","locale":"c\\d"},)" + R"("committedWord":"w\"x","pointers":[]})"; + const TraceFixture fix = parseFixture(json); + EXPECT_EQ("a\"b", fix.keyboard.mainLayout); + EXPECT_EQ("c\\d", fix.keyboard.locale); + EXPECT_EQ("w\"x", fix.committedWord); +} + +TEST(TraceFixtureParserTest, ParsesEmptyPointerArray) { + const std::string json = + R"({"version":1,"createdAt":0,)" + R"("keyboard":{"width":0,"height":0,"mainLayout":"","locale":""},)" + R"("committedWord":"","pointers":[]})"; + const TraceFixture fix = parseFixture(json); + EXPECT_EQ(0, fix.inputSize()); + EXPECT_TRUE(fix.pointers.empty()); +} + +TEST(TraceFixtureParserTest, ToleratesUnknownTopLevelKeys) { + // Ensures forward-compatibility: extra keys are silently skipped. + const std::string json = + R"({"version":1,"createdAt":0,"newField":{"nested":42},)" + R"("keyboard":{"width":0,"height":0,"mainLayout":"","locale":""},)" + R"("committedWord":"hi","pointers":[]})"; + EXPECT_NO_THROW({ + const TraceFixture fix = parseFixture(json); + EXPECT_EQ("hi", fix.committedWord); + }); +} + +// File-based test: only compiled when FIXTURE_DIR is passed by CMake. +#if defined(FIXTURE_DIR) +TEST(TraceFixtureParserTest, LoadsHelloQwertyFromFile) { + const std::string path = std::string(FIXTURE_DIR) + "/hello_qwerty.json"; + TraceFixture fix; + ASSERT_NO_THROW({ fix = loadFixture(path); }) << "path: " << path; + EXPECT_EQ(1, fix.version); + EXPECT_EQ("hello", fix.committedWord); + EXPECT_EQ(1080, fix.keyboard.width); + EXPECT_GT(fix.inputSize(), 0); +} +#endif + +// ============================================================================= +// DISABLED_GestureReplayTest — compile-checked scaffolding; skipped in ctest. +// +// Blocked by: +// 1. ProximityInfo requires JNIEnv* + jintArray/jfloatArray (JVM must be live). +// 2. Dictionary requires a binary .dict asset loaded from disk. +// +// To re-enable, prefix the test name with nothing (remove "DISABLED_") after +// both blockers are resolved. See file header for the resolution path. +// ============================================================================= + +// Prevent "unused include" warnings while the DISABLED_ tests are inactive. +// The includes below are intentional — they document the API seam and will be +// used once the blockers are lifted. +// +// #include "suggest/core/layout/proximity_info.h" // needs JNIEnv* +// #include "suggest/core/dictionary/dictionary.h" // needs .dict asset +// #include "suggest/core/session/dic_traverse_session.h" // needs JNIEnv* +// #include "suggest/core/result/suggestion_results.h" +// #include "suggest/core/suggest_options.h" +// #include "dictionary/property/ngram_context.h" + +TEST(DISABLED_GestureReplayTest, ReplayHelloQwerty) { + // --- Step 1: Load fixture --- + const TraceFixture fix = parseFixture(kHelloQwertyJson); + ASSERT_EQ("hello", fix.committedWord); + + // --- Step 2: Build ProximityInfo from fixture keyboard geometry --- + // + // BLOCKED: ProximityInfo constructor signature (proximity_info.h:31): + // + // ProximityInfo(JNIEnv *env, + // int keyboardWidth, int keyboardHeight, + // int gridWidth, int gridHeight, + // int mostCommonKeyWidth, int mostCommonKeyHeight, + // jintArray proximityChars, // JNI array — needs live JVM + // int keyCount, + // jintArray keyXCoordinates, // JNI array + // jintArray keyYCoordinates, // JNI array + // jintArray keyWidths, // JNI array + // jintArray keyHeights, // JNI array + // jintArray keyCharCodes, // JNI array + // jfloatArray sweetSpotCenterXs, // JNI float array + // jfloatArray sweetSpotCenterYs, // JNI float array + // jfloatArray sweetSpotRadii); // JNI float array + // + // The body immediately calls env->GetArrayLength(proximityChars) and + // env->GetIntArrayRegion(...), so even a null JNIEnv* would crash. + // Resolution: add a non-JNI constructor that accepts raw int*/float* arrays. + + // ProximityInfo pInfo(env, fix.keyboard.width, fix.keyboard.height, ...); + + // --- Step 3: Load a binary dictionary --- + // + // BLOCKED: requires a compiled en_US .dict binary. The latinime app loads + // these from its APK assets folder at runtime; they are not in the source + // tree. For host tests, a small test dictionary must be bundled (e.g. via + // CMake FetchContent) or generated programmatically using the v4 writer. + + // const char *dictPath = "/path/to/en_US.dict"; + // auto policy = DictionaryStructureWithBufferPolicyFactory::newPolicyForExistingDictFile( + // dictPath, 0, fileSize, false); + // Dictionary dict(env, std::move(policy)); + + // --- Step 4: Create DicTraverseSession --- + // + // DicTraverseSession also takes JNIEnv* + jstring locale (same blocker). + + // DicTraverseSession session(env, localeJstr, /*usesLargeCache=*/false); + // session.init(&dict, nullptr, &suggestOptions); + + // --- Step 5: Call getSuggestions --- + // + // Suggest::getSuggestions(ProximityInfo*, void* traverseSession, + // int* inputXs, int* inputYs, int* times, int* pointerIds, + // int* inputCodePoints, int inputSize, + // float weightOfLangModelVsSpatialModel, + // SuggestionResults* outSuggestionResults) + // + // Once unblocked, wire like: + // + // auto xs = fix.xCoordinates(); + // auto ys = fix.yCoordinates(); + // auto ts = fix.times(); + // auto ids = fix.pointerIds(); + // SuggestionResults results(MAX_RESULTS); + // NgramContext ngramCtx; + // SuggestOptions opts; + // dict.getSuggestions(&pInfo, &session, + // xs.data(), ys.data(), ts.data(), ids.data(), + // /*inputCodePoints=*/nullptr, fix.inputSize(), + // &ngramCtx, &opts, + // NOT_A_WEIGHT_OF_LANG_MODEL_VS_SPATIAL_MODEL, + // &results); + // + // Then assert the top suggestion matches committedWord: + // + // ASSERT_GT(results.getSuggestionsCount(), 0); + // int topWordCodePoints[MAX_WORD_LENGTH]; + // results.getSortedScores(); // sort descending + // // decode first result and compare to fix.committedWord + // char topWord[MAX_WORD_LENGTH * 4 + 1]; + // intArrayToCharArray(topWordCodePoints, topWordLen, topWord, sizeof(topWord)); + // EXPECT_EQ(fix.committedWord, std::string(topWord)); + + GTEST_SKIP() << "GestureReplayTest disabled: see file header for blocker details."; +} + +} // namespace +} // namespace replay +} // namespace latinime diff --git a/app/src/main/jni/tests/replay/trace_fixture.h b/app/src/main/jni/tests/replay/trace_fixture.h new file mode 100644 index 000000000..eab55b7d3 --- /dev/null +++ b/app/src/main/jni/tests/replay/trace_fixture.h @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Fixture loader for native gesture-replay tests (issue #78, deliverable 2). +// +// Parses the TraceRecorder JSON schema (version 1) into plain C++ structs whose +// raw int arrays can be forwarded to the latinime recognizer APIs once the JNI +// blocker is lifted (see gesture_replay_test.cpp). +// +// No external dependencies: header-only, C++17, standard library only. + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace latinime { +namespace replay { + +// ---- Data model ------------------------------------------------------------- + +struct KeyboardMeta { + int width = 0; + int height = 0; + std::string mainLayout; + std::string locale; +}; + +struct PointerSample { + int id = 0; + int x = 0; + int y = 0; + int t = 0; +}; + +struct TraceFixture { + int version = 0; + long long createdAt = 0; + KeyboardMeta keyboard; + std::string committedWord; + std::vector pointers; + + // Convenience accessors — return plain int vectors suitable for + // Dictionary::getSuggestions / Suggest::getSuggestions once those are + // reachable in the host build (see DISABLED_ tests). + std::vector xCoordinates() const { + std::vector v(pointers.size()); + for (std::size_t i = 0; i < pointers.size(); ++i) v[i] = pointers[i].x; + return v; + } + std::vector yCoordinates() const { + std::vector v(pointers.size()); + for (std::size_t i = 0; i < pointers.size(); ++i) v[i] = pointers[i].y; + return v; + } + std::vector times() const { + std::vector v(pointers.size()); + for (std::size_t i = 0; i < pointers.size(); ++i) v[i] = pointers[i].t; + return v; + } + std::vector pointerIds() const { + std::vector v(pointers.size()); + for (std::size_t i = 0; i < pointers.size(); ++i) v[i] = pointers[i].id; + return v; + } + int inputSize() const { return static_cast(pointers.size()); } +}; + +// ---- Minimal JSON parser ---------------------------------------------------- +// Only handles the exact TraceRecorder schema; not a general-purpose parser. + +namespace detail { + +struct Parser { + const char *p; + const char *end; + + explicit Parser(const std::string &s) + : p(s.data()), end(s.data() + s.size()) {} + + void skipWs() { + while (p < end && std::isspace(static_cast(*p))) ++p; + } + + bool peek(char c) { skipWs(); return p < end && *p == c; } + + bool consume(char c) { + if (!peek(c)) return false; + ++p; + return true; + } + + void expect(char c) { + if (!consume(c)) { + throw std::runtime_error( + std::string("TraceFixture JSON: expected '") + c + + "', got '" + (p < end ? std::string(1, *p) : "EOF") + "'"); + } + } + + std::string parseString() { + expect('"'); + std::string out; + while (p < end && *p != '"') { + if (*p == '\\') { + ++p; + if (p < end) { + switch (*p) { + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + default: out += *p; break; + } + ++p; + } + } else { + out += *p++; + } + } + expect('"'); + return out; + } + + long long parseInt() { + skipWs(); + bool neg = false; + if (p < end && *p == '-') { neg = true; ++p; } + if (p >= end || !std::isdigit(static_cast(*p))) + throw std::runtime_error("TraceFixture JSON: expected digit"); + long long v = 0; + while (p < end && std::isdigit(static_cast(*p))) + v = v * 10 + (*p++ - '0'); + return neg ? -v : v; + } + + // parseObject: for each key, calls f(key) and f must consume the value. + template + void parseObject(F f) { + expect('{'); + skipWs(); + if (peek('}')) { ++p; return; } + do { + skipWs(); + std::string key = parseString(); + skipWs(); + expect(':'); + f(key); + skipWs(); + } while (consume(',')); + expect('}'); + } + + // parseArray: calls f() for each element; f must consume the element. + template + void parseArray(F f) { + expect('['); + skipWs(); + if (peek(']')) { ++p; return; } + do { + skipWs(); + f(); + skipWs(); + } while (consume(',')); + expect(']'); + } + + void skipValue() { + skipWs(); + if (peek('"')) { parseString(); return; } + if (peek('{')) { + parseObject([this](const std::string &) { skipValue(); }); + return; + } + if (peek('[')) { + parseArray([this]() { skipValue(); }); + return; + } + // number, true, false, null + while (p < end && *p != ',' && *p != '}' && *p != ']' && + !std::isspace(static_cast(*p))) + ++p; + } +}; + +} // namespace detail + +// Parse a JSON string conforming to the TraceRecorder schema (version 1). +// Throws std::runtime_error on malformed input. +inline TraceFixture parseFixture(const std::string &json) { + detail::Parser par(json); + TraceFixture fix; + + par.parseObject([&](const std::string &key) { + if (key == "version") { + fix.version = static_cast(par.parseInt()); + } else if (key == "createdAt") { + fix.createdAt = par.parseInt(); + } else if (key == "committedWord") { + fix.committedWord = par.parseString(); + } else if (key == "keyboard") { + par.parseObject([&](const std::string &kk) { + if (kk == "width") fix.keyboard.width = static_cast(par.parseInt()); + else if (kk == "height") fix.keyboard.height = static_cast(par.parseInt()); + else if (kk == "mainLayout") fix.keyboard.mainLayout = par.parseString(); + else if (kk == "locale") fix.keyboard.locale = par.parseString(); + else par.skipValue(); + }); + } else if (key == "pointers") { + par.parseArray([&]() { + PointerSample ps; + par.parseObject([&](const std::string &pk) { + if (pk == "id") ps.id = static_cast(par.parseInt()); + else if (pk == "x") ps.x = static_cast(par.parseInt()); + else if (pk == "y") ps.y = static_cast(par.parseInt()); + else if (pk == "t") ps.t = static_cast(par.parseInt()); + else par.skipValue(); + }); + fix.pointers.push_back(ps); + }); + } else { + par.skipValue(); + } + }); + + return fix; +} + +// Load and parse a fixture from a JSON file. +// Throws std::runtime_error if the file cannot be opened or is malformed. +inline TraceFixture loadFixture(const std::string &path) { + std::ifstream f(path); + if (!f) throw std::runtime_error("Cannot open fixture file: " + path); + std::ostringstream ss; + ss << f.rdbuf(); + return parseFixture(ss.str()); +} + +} // namespace replay +} // namespace latinime From ab36af94a7e44978746a20faba905fa4c70d65b1 Mon Sep 17 00:00:00 2001 From: AsafMah Date: Fri, 12 Jun 2026 19:42:08 +0300 Subject: [PATCH 3/6] docs(spacing): practical playtest plan for tap/swipe timing (#103) Split from the parked A11/tuning stack. Documents the actual axes discovered on-device: tap-only vs swipe vs tap-then-swipe, and auto-finish vs auto-space vs deferred-space timing. Includes concrete presets and playtest scenarios for shortcut safety, swiped complete words, tap-then-swipe extension, correction replacement, and punctuation/deferred spacing. --- docs/SPACING_TEST_PLAN.md | 275 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/SPACING_TEST_PLAN.md diff --git a/docs/SPACING_TEST_PLAN.md b/docs/SPACING_TEST_PLAN.md new file mode 100644 index 000000000..f7705ca21 --- /dev/null +++ b/docs/SPACING_TEST_PLAN.md @@ -0,0 +1,275 @@ +# Spacing Policy Playtest Plan + +Use this when tuning the two-thumb spacing policy. The goal is practical feel, not reading raw telemetry. + +The most important distinction: + +- **Auto-finish** = when the word commits. +- **Auto-space** = when/if a trailing space appears after that commit. + +Those are separate. Several settings sound similar but affect different stages. + +--- + +## 1. Timing model cheat sheet + +| Setting | Affects | Tap-only words | Swiped words | Tap-then-swipe words | +|---|---|---|---|---| +| **Grace timer** | base auto-finish delay | yes, unless swipe-only finish is ON | yes | yes | +| **Tap extra grace** | extra delay for tap-started combining | yes, unless swipe-only finish is ON | no | yes before the swipe extends | +| **Only auto-finish swiped words** | whether tap-only words can auto-commit | **blocks tap-only auto-finish** | no effect | still allows auto-finish after the swipe fragment | +| **Adapt pause to the word** | modifies the grace timer by word signals | only if the word is allowed to auto-finish | yes | yes after the swipe fragment | +| **Only auto-space after swipes** | trailing space only | tap word may still commit, just no space | allows space | allows space after swiped fragment | +| **Defer grace space** | when the space materializes | no change to commit timing | no change to commit timing | no change to commit timing | + +Recommended safety defaults: + +```text +Only auto-finish swiped words: ON +Only auto-space after swipes: ON if you dislike tap-created spaces +Adapt pause to the word: ON only while tuning / testing +Defer grace space: ON only while testing deferred-space feel +``` + +--- + +## 2. Presets to try + +Do not tune one slider at a time first. Try a complete profile. + +### Profile A — Conservative / safe + +```text +Base grace timer: 650 ms +Tap extra grace: 250 ms +Finished-word speed-up: 150 ms +Extendable-stem patience: 700 ms +Only auto-finish swiped words: ON +Adapt pause to the word: ON +``` + +Expected: +- swiped complete words: about 500 ms +- swiped prefix-rich stems: roughly 900–1200 ms +- tap-only words: no auto-finish + +### Profile B — Balanced + +```text +Base grace timer: 550 ms +Tap extra grace: 250 ms +Finished-word speed-up: 250 ms +Extendable-stem patience: 600 ms +Only auto-finish swiped words: ON +Adapt pause to the word: ON +``` + +Expected: +- swiped complete words: about 300 ms +- medium stems: about 700–900 ms +- prefix-heavy stems: about 1000 ms + +### Profile C — Fast / assisted + +```text +Base grace timer: 450 ms +Tap extra grace: 250 ms +Finished-word speed-up: 300 ms +Extendable-stem patience: 650 ms +Only auto-finish swiped words: ON +Adapt pause to the word: ON +``` + +Expected: +- swiped complete words: about 150–250 ms +- stems still protected by patience + +### Profile D — Debug / exaggerated + +```text +Base grace timer: 600 ms +Tap extra grace: 250 ms +Finished-word speed-up: 450 ms +Extendable-stem patience: 1000 ms +Only auto-finish swiped words: ON +Adapt pause to the word: ON +``` + +Expected: +- swiped complete words commit very fast +- `ba`, `pre`, `con` wait a long time + +Use this only to understand the system. + +--- + +## 3. HUD labels + +Enable **Experimental → Draw gesture debug points**. + +HUD labels: + +- `FAST Nms · finished word` — complete dictionary word; timer shortened. +- `WAIT Nms · many continuations` — prefix-rich stem; timer lengthened. +- `TIMER Nms · not complete` — normal timer; no complete-word signal yet. +- `INSTANT` / `PAUSE` — Assisted-tier gate decision once enabled. + +If the HUD says `WAIT` for a word you expected to be finished, the dictionary/suggestion signal thinks many continuations are plausible. + +--- + +## 4. Practical tests + +### Test A — tap-only safety + +Input method: **tap only**. + +Use: +- `ba` (saved shortcut if available) +- any short typed word + +Settings: +- **Only auto-finish swiped words: ON** + +Expected: +- no auto-commit +- no Text Expander expansion +- no correction append behavior +- word stays composing until you press space or pick a suggestion + +If this fails, the swipe-only finish gate is broken. + +### Test B — swiped complete words should finish faster + +Input method: **swipe**. + +Try: +- `the` +- `and` +- `I` +- `hello` + +Expected with **Adapt pause to the word ON**: +- HUD says **FAST** +- word commits sooner than with Adapt OFF +- no need to tap space if the grace auto-space settings allow it + +Tuning: +- Too eager → lower **Finished-word speed-up** or raise base grace. +- Too slow → raise **Finished-word speed-up** or lower base grace. + +### Test C — swiped extendable stems should stay open longer + +Input method: **swipe or partial swipe**. + +Try stems: +- `ba` (bad / bar / bat / ball / back / bank) +- `ca` (can / car / cat / call / came) +- `pre` (pretty / press / prefer / previous) +- `con` (continue / control / content / consider) + +Expected: +- HUD says **WAIT** +- word does not auto-finish immediately +- you can keep extending without fighting the timer + +Tuning: +- Still commits too soon → raise **Extendable-stem patience** or base grace. +- Too sticky / never finishes → lower **Extendable-stem patience**. + +### Test D — tap-then-swipe still extends + +Input method: **tap prefix, then swipe rest**. + +Example: +- tap `fire` +- swipe `truck` + +Expected: +- tap prefix does not auto-finish by itself +- after the swipe fragment, the combined word still auto-finishes normally +- it should not become `fire firetruck` + +This verifies that **Only auto-finish swiped words** suppresses only the tap timer, not combining-mode entry. + +### Test E — Adapt ON vs OFF comparison + +Input methods: **swipe**, plus tap-only safety check. + +Words: +- `the` +- `ba` +- `pre` + +Expected: +- Adapt OFF: same pause for all swiped words/stems. +- Adapt ON: complete words faster, prefix-rich stems slower. +- Tap-only words still do not auto-finish when swipe-only finish is ON. + +If you cannot feel a difference: + +```text +Finished-word speed-up = 350 ms +Extendable-stem patience = 700 ms +``` + +### Test F — corrections replace, not append + +Input method: **tap only**. + +1. Misspell a word by tapping. +2. Wait briefly. +3. Pick the correction from the suggestion strip. + +Expected: +- correction replaces the misspelled word +- it does not append a second word + +If this fails, first confirm **Only auto-finish swiped words** is ON. The historical append bug came from the grace timer auto-committing tap words before the pick. + +### Test G — punctuation and deferred spacing + +Input method: **swipe**. + +With **Defer grace space** ON, try: +- `hello.` +- `the,` +- `word?` + +Expected: +- no double spaces +- no space before punctuation +- backspace after a grace commit still removes the right thing + +Remember: this tests **space materialization**, not auto-finish timing. + +--- + +## 5. Recording results + +For each run, note: + +```text +base grace: +tap extra grace: +finished-word speed-up: +extendable-stem patience: +Adapt pause to the word: on/off +Only auto-finish swiped words: on/off +Only auto-space after swipes: on/off +Defer grace space: on/off +input method: tap / swipe / tap-then-swipe +word: +HUD: +result: +``` + +Good tuning notes look like: + +```text +base 550, tap extra 250, speed-up 250, patience 600 +swipe "the" -> FAST 300ms, felt right +swipe "ba" -> WAIT 910ms, still too fast, raised patience +TAP shortcut "ba" -> stayed composing, good +TAP then SWIPE fire+truck -> combined and committed, good +``` From 8e7621ede503302373295fa026c3a4afb5ad81e3 Mon Sep 17 00:00:00 2001 From: AsafMah Date: Sat, 13 Jun 2026 17:48:34 +0300 Subject: [PATCH 4/6] test(native): make ProximityInfo host-constructible for replay harness (#78) (#104) Follow-up proof spike after #96. Add a non-JNI ProximityInfo constructor that accepts the same keyboard geometry as raw int/float arrays, so native host tests can build a keyboard geometry object without a live JVM/JNIEnv. Adds GestureReplayHostSeamTest.BuildsProximityInfoWithoutJNI: constructs a minimal QWERTY ProximityInfo and verifies key lookup for the 'hello' trace. Attempted to enable the full replay assertion with an in-memory dict; ASAN showed the next blocker is more fundamental than a dict asset: the open-source tree has no GestureSuggestPolicy implementation, only GestureSuggestPolicyFactory, whose factory method is null in host builds. Dictionary::getSuggestions(..., IS_GESTURE) therefore crashes at TRAVERSAL->getMaxSpatialDistance(). The disabled replay scaffold now documents that real blocker. Verification (WSL / ubuntu-like): native host ctest excluding the known FormatUtils quarantine -> 77/77 enabled tests pass, 1 disabled replay assertion. --- .../suggest/core/layout/proximity_info.cpp | 62 ++++++ .../src/suggest/core/layout/proximity_info.h | 12 ++ .../jni/tests/replay/gesture_replay_test.cpp | 196 +++++++----------- 3 files changed, 148 insertions(+), 122 deletions(-) diff --git a/app/src/main/jni/src/suggest/core/layout/proximity_info.cpp b/app/src/main/jni/src/suggest/core/layout/proximity_info.cpp index 933a5e145..3195a2450 100644 --- a/app/src/main/jni/src/suggest/core/layout/proximity_info.cpp +++ b/app/src/main/jni/src/suggest/core/layout/proximity_info.cpp @@ -49,6 +49,24 @@ static AK_FORCE_INLINE void safeGetOrFillZeroFloatArrayRegion(JNIEnv *env, jfloa } } +static AK_FORCE_INLINE void copyOrFillZeroIntArray(const int *const source, + const int len, int *const buffer) { + if (source && buffer) { + memcpy(buffer, source, len * sizeof(buffer[0])); + } else if (buffer) { + memset(buffer, 0, len * sizeof(buffer[0])); + } +} + +static AK_FORCE_INLINE void copyOrFillZeroFloatArray(const float *const source, + const int len, float *const buffer) { + if (source && buffer) { + memcpy(buffer, source, len * sizeof(buffer[0])); + } else if (buffer) { + memset(buffer, 0, len * sizeof(buffer[0])); + } +} + ProximityInfo::ProximityInfo(JNIEnv *env, const int keyboardWidth, const int keyboardHeight, const int gridWidth, const int gridHeight, const int mostCommonKeyWidth, const int mostCommonKeyHeight, const jintArray proximityChars, const int keyCount, @@ -95,6 +113,50 @@ ProximityInfo::ProximityInfo(JNIEnv *env, const int keyboardWidth, const int key initializeG(); } +ProximityInfo::ProximityInfo(const int keyboardWidth, const int keyboardHeight, + const int gridWidth, const int gridHeight, const int mostCommonKeyWidth, + const int mostCommonKeyHeight, const int *const proximityChars, + const int proximityCharsLength, const int keyCount, + const int *const keyXCoordinates, const int *const keyYCoordinates, + const int *const keyWidths, const int *const keyHeights, + const int *const keyCharCodes, const float *const sweetSpotCenterXs, + const float *const sweetSpotCenterYs, const float *const sweetSpotRadii) + : GRID_WIDTH(gridWidth), GRID_HEIGHT(gridHeight), MOST_COMMON_KEY_WIDTH(mostCommonKeyWidth), + MOST_COMMON_KEY_WIDTH_SQUARE(mostCommonKeyWidth * mostCommonKeyWidth), + NORMALIZED_SQUARED_MOST_COMMON_KEY_HYPOTENUSE(1.0f + + GeometryUtils::SQUARE_FLOAT(static_cast(mostCommonKeyHeight) / + static_cast(mostCommonKeyWidth))), + CELL_WIDTH((keyboardWidth + gridWidth - 1) / gridWidth), + CELL_HEIGHT((keyboardHeight + gridHeight - 1) / gridHeight), + KEY_COUNT(std::min(keyCount, MAX_KEY_COUNT_IN_A_KEYBOARD)), + KEYBOARD_WIDTH(keyboardWidth), KEYBOARD_HEIGHT(keyboardHeight), + KEYBOARD_HYPOTENUSE(hypotf(KEYBOARD_WIDTH, KEYBOARD_HEIGHT)), + HAS_TOUCH_POSITION_CORRECTION_DATA(keyCount > 0 && keyXCoordinates && keyYCoordinates + && keyWidths && keyHeights && keyCharCodes && sweetSpotCenterXs + && sweetSpotCenterYs && sweetSpotRadii), + mProximityCharsArray(new int[GRID_WIDTH * GRID_HEIGHT * MAX_PROXIMITY_CHARS_SIZE + /* proximityCharsLength */]), + mLowerCodePointToKeyMap() { + const int expectedLength = GRID_WIDTH * GRID_HEIGHT * MAX_PROXIMITY_CHARS_SIZE; + if (proximityCharsLength != expectedLength) { + AKLOGE("Invalid host proximityCharsLength: %d expected: %d", proximityCharsLength, + expectedLength); + ASSERT(false); + memset(mProximityCharsArray, 0, expectedLength * sizeof(mProximityCharsArray[0])); + } else { + copyOrFillZeroIntArray(proximityChars, expectedLength, mProximityCharsArray); + } + copyOrFillZeroIntArray(keyXCoordinates, KEY_COUNT, mKeyXCoordinates); + copyOrFillZeroIntArray(keyYCoordinates, KEY_COUNT, mKeyYCoordinates); + copyOrFillZeroIntArray(keyWidths, KEY_COUNT, mKeyWidths); + copyOrFillZeroIntArray(keyHeights, KEY_COUNT, mKeyHeights); + copyOrFillZeroIntArray(keyCharCodes, KEY_COUNT, mKeyCodePoints); + copyOrFillZeroFloatArray(sweetSpotCenterXs, KEY_COUNT, mSweetSpotCenterXs); + copyOrFillZeroFloatArray(sweetSpotCenterYs, KEY_COUNT, mSweetSpotCenterYs); + copyOrFillZeroFloatArray(sweetSpotRadii, KEY_COUNT, mSweetSpotRadii); + initializeG(); +} + ProximityInfo::~ProximityInfo() { delete[] mProximityCharsArray; } diff --git a/app/src/main/jni/src/suggest/core/layout/proximity_info.h b/app/src/main/jni/src/suggest/core/layout/proximity_info.h index f7c907697..d882e9dc2 100644 --- a/app/src/main/jni/src/suggest/core/layout/proximity_info.h +++ b/app/src/main/jni/src/suggest/core/layout/proximity_info.h @@ -35,6 +35,18 @@ class ProximityInfo { const jintArray keyYCoordinates, const jintArray keyWidths, const jintArray keyHeights, const jintArray keyCharCodes, const jfloatArray sweetSpotCenterXs, const jfloatArray sweetSpotCenterYs, const jfloatArray sweetSpotRadii); + + // Host-test constructor: same data as the JNI constructor, but passed as raw arrays so + // native unit tests can build ProximityInfo without a live JVM/JNIEnv. + ProximityInfo(const int keyboardWidth, const int keyboardHeight, + const int gridWidth, const int gridHeight, + const int mostCommonKeyWidth, const int mostCommonKeyHeight, + const int *const proximityChars, const int proximityCharsLength, + const int keyCount, const int *const keyXCoordinates, + const int *const keyYCoordinates, const int *const keyWidths, + const int *const keyHeights, const int *const keyCharCodes, + const float *const sweetSpotCenterXs, const float *const sweetSpotCenterYs, + const float *const sweetSpotRadii); ~ProximityInfo(); bool hasSpaceProximity(const int x, const int y) const; float getNormalizedSquaredDistanceFromCenterFloatG( diff --git a/app/src/main/jni/tests/replay/gesture_replay_test.cpp b/app/src/main/jni/tests/replay/gesture_replay_test.cpp index 5c9944372..07f01a728 100644 --- a/app/src/main/jni/tests/replay/gesture_replay_test.cpp +++ b/app/src/main/jni/tests/replay/gesture_replay_test.cpp @@ -10,35 +10,31 @@ // // DISABLED tests (DISABLED_GestureReplayTest.*) // Compile-checked stubs that would feed a loaded trace through the latinime -// gesture recognizer. Disabled because two blockers must be resolved first: +// gesture recognizer. Disabled because the open-source tree does NOT contain +// a gesture suggest policy implementation: it only has GestureSuggestPolicyFactory, +// whose factory method is null in the host build. Dictionary::getSuggestions(...) +// with IS_GESTURE therefore constructs Suggest with a null policy and would crash +// at TRAVERSAL->getMaxSpatialDistance(). // -// BLOCKER 1 — JNI runtime required by ProximityInfo: -// ProximityInfo::ProximityInfo(JNIEnv*, ...) calls env->GetArrayLength() and -// env->GetIntArrayRegion() unconditionally on the very first line of the -// constructor body (proximity_info.cpp:76). There is no non-JNI constructor -// overload. A live JVM (JavaVM + JNI_CreateJavaVM, or an Android runtime) -// must be available to create a ProximityInfo with real keyboard geometry. +// What is proven here: +// a) TraceRecorder-style fixtures parse into the exact x/y/time/pointer arrays +// expected by Dictionary::getSuggestions. +// b) ProximityInfo can now be constructed on the host from raw arrays — no JNIEnv +// required — and QWERTY key lookup works. // -// BLOCKER 2 — binary dictionary asset required: -// Dictionary wraps a DictionaryStructureWithBufferPolicy that is loaded from -// a compiled binary .dict file (the Android asset pipeline provides these at -// runtime; they are not part of the source tree and are not downloaded by the -// CMake build). Without a valid dict the gesture scorer has nothing to search. -// -// Next concrete steps to get real replay assertions: -// a) Either create a minimal fake JNIEnv shim (filling JNINativeInterface_ -// function pointers) so ProximityInfo can be constructed on the host, OR -// add a non-JNI constructor that accepts raw int*/float* arrays directly. -// b) Download / bundle a small compiled English .dict (e.g. the AOSP -// "en_US" dict, ~1 MB) as a test asset via CMake FetchContent, OR -// generate a tiny programmatic dict using the existing v4 writer classes. -// c) Wire ProximityInfo + Dictionary + DicTraverseSession together and -// uncomment the assertion in DISABLED_GestureReplayTest.ReplayHelloQwerty. +// Next concrete step to get real replay assertions: +// provide an open/host-buildable GestureSuggestPolicy implementation (e.g. the +// future NLnet recognizer) or a test double with the same policy interface. Once +// GestureSuggestPolicyFactory returns a real policy, this scaffold can wire the +// fixture + ProximityInfo + Dictionary together and assert the suggestion. // ============================================================================= #include +#include +#include #include "replay/trace_fixture.h" +#include "suggest/core/layout/proximity_info.h" namespace latinime { namespace replay { @@ -194,112 +190,68 @@ TEST(TraceFixtureParserTest, LoadsHelloQwertyFromFile) { } #endif -// ============================================================================= -// DISABLED_GestureReplayTest — compile-checked scaffolding; skipped in ctest. -// -// Blocked by: -// 1. ProximityInfo requires JNIEnv* + jintArray/jfloatArray (JVM must be live). -// 2. Dictionary requires a binary .dict asset loaded from disk. -// -// To re-enable, prefix the test name with nothing (remove "DISABLED_") after -// both blockers are resolved. See file header for the resolution path. -// ============================================================================= - -// Prevent "unused include" warnings while the DISABLED_ tests are inactive. -// The includes below are intentional — they document the API seam and will be -// used once the blockers are lifted. -// -// #include "suggest/core/layout/proximity_info.h" // needs JNIEnv* -// #include "suggest/core/dictionary/dictionary.h" // needs .dict asset -// #include "suggest/core/session/dic_traverse_session.h" // needs JNIEnv* -// #include "suggest/core/result/suggestion_results.h" -// #include "suggest/core/suggest_options.h" -// #include "dictionary/property/ngram_context.h" - -TEST(DISABLED_GestureReplayTest, ReplayHelloQwerty) { - // --- Step 1: Load fixture --- - const TraceFixture fix = parseFixture(kHelloQwertyJson); - ASSERT_EQ("hello", fix.committedWord); - - // --- Step 2: Build ProximityInfo from fixture keyboard geometry --- - // - // BLOCKED: ProximityInfo constructor signature (proximity_info.h:31): - // - // ProximityInfo(JNIEnv *env, - // int keyboardWidth, int keyboardHeight, - // int gridWidth, int gridHeight, - // int mostCommonKeyWidth, int mostCommonKeyHeight, - // jintArray proximityChars, // JNI array — needs live JVM - // int keyCount, - // jintArray keyXCoordinates, // JNI array - // jintArray keyYCoordinates, // JNI array - // jintArray keyWidths, // JNI array - // jintArray keyHeights, // JNI array - // jintArray keyCharCodes, // JNI array - // jfloatArray sweetSpotCenterXs, // JNI float array - // jfloatArray sweetSpotCenterYs, // JNI float array - // jfloatArray sweetSpotRadii); // JNI float array - // - // The body immediately calls env->GetArrayLength(proximityChars) and - // env->GetIntArrayRegion(...), so even a null JNIEnv* would crash. - // Resolution: add a non-JNI constructor that accepts raw int*/float* arrays. - - // ProximityInfo pInfo(env, fix.keyboard.width, fix.keyboard.height, ...); +static std::vector buildEmptyProximityChars(const int gridWidth, const int gridHeight) { + return std::vector(gridWidth * gridHeight * MAX_PROXIMITY_CHARS_SIZE, NOT_A_CODE_POINT); +} - // --- Step 3: Load a binary dictionary --- - // - // BLOCKED: requires a compiled en_US .dict binary. The latinime app loads - // these from its APK assets folder at runtime; they are not in the source - // tree. For host tests, a small test dictionary must be bundled (e.g. via - // CMake FetchContent) or generated programmatically using the v4 writer. +TEST(GestureReplayHostSeamTest, BuildsProximityInfoWithoutJNI) { + // Minimal QWERTY row geometry sufficient to prove the replay harness can construct + // ProximityInfo from raw host arrays. The full recognizer assertion is still blocked on + // a binary .dict asset; this removes the JNIEnv blocker. + constexpr int keyboardWidth = 1080; + constexpr int keyboardHeight = 310; + constexpr int gridWidth = 10; + constexpr int gridHeight = 5; + constexpr int keyWidth = 108; + constexpr int keyHeight = 90; + const char *letters = "qwertyuiopasdfghjklzxcvbnm"; + constexpr int keyCount = 26; - // const char *dictPath = "/path/to/en_US.dict"; - // auto policy = DictionaryStructureWithBufferPolicyFactory::newPolicyForExistingDictFile( - // dictPath, 0, fileSize, false); - // Dictionary dict(env, std::move(policy)); + int xs[keyCount]; + int ys[keyCount]; + int widths[keyCount]; + int heights[keyCount]; + int codes[keyCount]; + float sweetXs[keyCount]; + float sweetYs[keyCount]; + float radii[keyCount]; - // --- Step 4: Create DicTraverseSession --- - // - // DicTraverseSession also takes JNIEnv* + jstring locale (same blocker). + for (int i = 0; i < keyCount; ++i) { + const int row = i < 10 ? 0 : (i < 19 ? 1 : 2); + const int col = i < 10 ? i : (i < 19 ? i - 10 : i - 19); + const int rowOffset = row == 0 ? 0 : (row == 1 ? keyWidth / 2 : keyWidth); + xs[i] = rowOffset + col * keyWidth; + ys[i] = row * keyHeight; + widths[i] = keyWidth; + heights[i] = keyHeight; + codes[i] = letters[i]; + sweetXs[i] = xs[i] + keyWidth / 2.0f; + sweetYs[i] = ys[i] + keyHeight / 2.0f; + radii[i] = keyWidth / 2.0f; + } + const std::vector proximityChars = buildEmptyProximityChars(gridWidth, gridHeight); + ProximityInfo info(keyboardWidth, keyboardHeight, gridWidth, gridHeight, keyWidth, keyHeight, + proximityChars.data(), static_cast(proximityChars.size()), keyCount, + xs, ys, widths, heights, codes, sweetXs, sweetYs, radii); - // DicTraverseSession session(env, localeJstr, /*usesLargeCache=*/false); - // session.init(&dict, nullptr, &suggestOptions); + EXPECT_EQ(keyCount, info.getKeyCount()); + EXPECT_TRUE(info.isCodePointOnKeyboard('h')); + EXPECT_TRUE(info.isCodePointOnKeyboard('e')); + EXPECT_TRUE(info.isCodePointOnKeyboard('l')); + EXPECT_TRUE(info.isCodePointOnKeyboard('o')); + EXPECT_FALSE(info.isCodePointOnKeyboard('#')); + EXPECT_EQ('h', info.getCodePointOf(info.getKeyIndexOf('h'))); +} - // --- Step 5: Call getSuggestions --- - // - // Suggest::getSuggestions(ProximityInfo*, void* traverseSession, - // int* inputXs, int* inputYs, int* times, int* pointerIds, - // int* inputCodePoints, int inputSize, - // float weightOfLangModelVsSpatialModel, - // SuggestionResults* outSuggestionResults) - // - // Once unblocked, wire like: - // - // auto xs = fix.xCoordinates(); - // auto ys = fix.yCoordinates(); - // auto ts = fix.times(); - // auto ids = fix.pointerIds(); - // SuggestionResults results(MAX_RESULTS); - // NgramContext ngramCtx; - // SuggestOptions opts; - // dict.getSuggestions(&pInfo, &session, - // xs.data(), ys.data(), ts.data(), ids.data(), - // /*inputCodePoints=*/nullptr, fix.inputSize(), - // &ngramCtx, &opts, - // NOT_A_WEIGHT_OF_LANG_MODEL_VS_SPATIAL_MODEL, - // &results); - // - // Then assert the top suggestion matches committedWord: - // - // ASSERT_GT(results.getSuggestionsCount(), 0); - // int topWordCodePoints[MAX_WORD_LENGTH]; - // results.getSortedScores(); // sort descending - // // decode first result and compare to fix.committedWord - // char topWord[MAX_WORD_LENGTH * 4 + 1]; - // intArrayToCharArray(topWordCodePoints, topWordLen, topWord, sizeof(topWord)); - // EXPECT_EQ(fix.committedWord, std::string(topWord)); +TEST(DISABLED_GestureReplayTest, ReplayHelloQwerty) { + const TraceFixture fix = parseFixture(kHelloQwertyJson); + ASSERT_EQ("hello", fix.committedWord); - GTEST_SKIP() << "GestureReplayTest disabled: see file header for blocker details."; + // The host replay harness can now parse the trace and construct ProximityInfo without JNI. + // It still cannot call the actual gesture recognizer because this open-source tree has no + // GestureSuggestPolicy implementation; GestureSuggestPolicyFactory::getGestureSuggestPolicy() + // returns nullptr in host tests. Enabling this assertion requires an open/host-buildable + // policy implementation (or a test policy) first. } } // namespace From 062fab2f97b0535123b6ad940ff2ef54ea3cdbe1 Mon Sep 17 00:00:00 2001 From: AsafMah Date: Mon, 22 Jun 2026 09:45:22 +0300 Subject: [PATCH 5/6] Merge upstream LeanType v3.8.6 (handwriting, llama.cpp/GGUF, touchpad gestures) (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(settings): fix and optimize gboard import Parse Gboard format header dynamically to fix missing words and swapped values. Optimize using bulkInsert to reduce database insertion IPC overhead. * chore: bump version to 3.8.4 Set versionCode to 3840 and versionName to 3.8.4 in build.gradle.kts. Create Fastlane changelog metadata at 3840.txt. * feat(settings): improve text expander ui/ux Add responsive search filtering for text expansion shortcuts. Add quick placeholder selection row with rich cursor-based insert support to Add/Edit Dialog. * feat(settings): polish text expander guide and list Redesign Quick Feature Guide card with styled step badges. Redesign custom shortcuts list items with premium keyword badges and chevrons. Redesign empty state with card illustration layout. * feat(settings): clean up text expander guide Remove redundant template placeholder explanation while retaining the list of supported placeholder tags. * feat(settings): add more expander placeholders Add support and UI representations for %month%, %month_short%, %year%, and %week% placeholders. * feat(settings): add system template placeholders Add and integrate support for %battery%, %device%, and %android% placeholders. * feat(settings): add language placeholder Add and integrate support for %language% placeholder, which expands to the current active keyboard display language. * fix(perf): prevent OOM on background image decode - Add BitmapUtils.decodeSampledBitmap() with two-pass decode and inSampleSize - Use RGB_565 config for non-PNG images to halve memory usage - Use BitmapFactory.decodeStream (with InputStream) instead of decodeFile - Cap background bitmap at 2048px max dimension - Recycle temp bitmap after validation in setBackgroundImage Fixes: Settings.java:527, BackgroundImagePreference.kt:122 * fix(stability): replace force-unwrap !! in hot paths - Colors.kt: use 'let' smart cast and 'error' instead of NPE on missing keyBackground - FloatingKeyboardManager.kt: safe-call on overlayRoot, early return on null - SuggestionStripView.kt: early return true on missing drawable Prevents IME process crashes from null drawables/bitmaps in keyboard rendering paths. * fix(stability): unregister SharedPreferences listener in spell-checker AndroidSpellCheckerService.onDestroy() now unregisters the OnSharedPreferenceChangeListener. Without this, the SharedPreferences implementation kept a strong reference to the service, leaking it through every spell-check session the system bound/unbound. * fix(stability): don't call Looper.prepare() on background thread BackupRestorePreference.kt was calling Looper.prepare() from the ScheduledThreadPool executor, leaking a Looper per restore and posting UI work onto an unreliable thread. Use Handler(Looper.getMainLooper()) to dispatch the FeedbackManager.message call to the UI thread. * fix(stability): make score-limit cache update atomic in Suggest The previous implementation had a non-atomic read-then-write of mLastScoreLimitUpdateTime and mCachedScoreLimitForAutocorrect across threads (suggestion lookup can happen on background threads via SuggestionSpan / TextClassifier). Two threads could both miss the interval check and recompute, with the second write overwriting the first. Wrap the cache update in synchronized(this) to make the check and update atomic. * fix(stability): use named lock for dictionary blacklist Three blacklist operations in DictionaryGroup used `.apply { scope.launch { synchronized(this) { ... } } }`, which re-bound `this` to the HashSet / CoroutineScope inside the synchronized block. Two threads could enter the critical section concurrently because they were locking on different objects. Add an explicit `blacklistLock: Any` and synchronize on it. * perf(perf): add key= to Lazy* list items for stable identity - SearchScreen: key groups by titleRes, items by toString() - ListPickerDialog, MultiListPickerDialog: key items by toString() - LayoutPickerDialog: key by layout name - ToolbarKeysCustomizer: key by enum name - ColorThemePickerDialog: key by color name Without keys, LazyColumn uses positional keys, causing every visible item to be recomposed (and its remember slots discarded) on every search keystroke or list mutation. * perf(perf): remember() expensive computations in Composables - SearchScreen: cache filteredItems(searchText.text) so it doesn't re-run the search filter on every parent recomposition (only when the search text actually changes) - MainSettingsScreen: cache SubtypeSettings.getEnabledSubtypes() and its joinToString() output so the description string is not rebuilt on every recomposition Both lists are otherwise recomputed on every pref change, every parent state change, and every scroll-induced recomposition. * perf(perf): avoid Paint allocation per recomposition in ColorPickerDialog Wrap the Paint in remember { } and assign to the controller inside a LaunchedEffect so the Paint is created once and not allocated on every recomposition. Also avoids re-assigning the controller's wheelPaint on every recomposition. * perf(perf): stream logcat to file instead of buffering in memory AboutScreen's 'Save log' was reading the entire logcat buffer into a single String via readText(), then writing it out. For a long-running device this can be several MB and compete with the IME process for memory, risking OOM on low-RAM devices. Use useLines { } to iterate line by line and write each one directly to the output stream. The internal log is now also streamed with a for loop and explicit toString() instead of a joinToString() that builds the entire list as a single String. * perf(perf): make ReorderSwitchPreference data class stable The private KeyAndState class had var fields that were mutated in the Switch.onCheckedChange callback, defeating Compose stability and forcing LazyColumn items to be rebuilt on every recomposition. - Make KeyAndState an immutable data class annotated with @Immutable - Hold the checked state in rememberSaveable(item.name) so the value survives recomposition but is per-item - Remove the in-place mutation of item.state in the Switch callback - rememberSaveable the items list so it's not re-parsed on every recomposition when the dialog is open * fix(perf): remove top-level MutableStateFlow in AIIntegrationScreen The previous top-level 'providerState' MutableStateFlow lived for the process lifetime and was mutated during composition. The state can be derived from the service on every composition (the service reads from SharedPreferences, which is cheap). - Replace top-level MutableStateFlow with a simple val read - Remove the no-op updateProviderState() function - Remove its call site in AdvancedScreen.kt The AIIntegrationScreen will pick up provider changes on the next composition (e.g. when the user navigates to it after changing the provider on the AdvancedScreen). * fix(perf): scope errorJob to the LayoutEditDialog composable The top-level 'private var errorJob: Job?' was shared between any two simultaneous instances of LayoutEditDialog, so opening a second dialog would cancel the first dialog's pending error feedback job. On configuration change the coroutine scope could be cancelled while the top-level job reference was leaked. Move the job into a per-composable remember { mutableStateOf(null) } and cancel/assign through errorJob.value. * fix(perf): replace GlobalScope in toolbar preference listener setToolbarButtonsActivatedStateOnPrefChange used GlobalScope.launch to defer a UI update by 10 ms, waiting for SettingsValues to reload after a SharedPreferences change. GlobalScope is uncancellable and its default exception handler converts failures into silent crashes. Replace it with a process-wide scope that uses SupervisorJob (so one failure cannot tear down sibling preference updates) and a logging CoroutineExceptionHandler. The function still hops to Dispatchers.Main before touching the view tree. * fix(perf): make SettingsNavHost navigateTo scope supervised The CoroutineScope backing the navigateTo() helper used a plain Job, so a single child failure would cancel the scope permanently. Add SupervisorJob so unrelated navigation hops keep working. * fix(stability): replace !! in colorFilter() helper createBlendModeColorFilterCompat returns a nullable ColorFilter, but the helper is only ever called with the supported BlendModeCompat modes (MODULATE, SRC_IN). Replace the !! with a Kotlin error() that throws IllegalStateException with a useful message if a new unsupported mode is ever introduced. * perf(perf): cache main-thread Handler in ClipboardHistoryManager ClipboardHistoryManager is a singleton scoped to the IME service, but it was creating a fresh Handler(Looper.getMainLooper()) on every postDelayed() and on every ContentObserver registration. The main Looper is process-wide and lives for the lifetime of the app, so a single cached Handler is enough. Replace the two ad-hoc Handler allocations in registerMediaStoreObserver and in the post-paste clip restoration path with a single 'mainHandler' field on the manager. * fix(stability): use SupervisorJob in RichInputMethodManager scope The CoroutineScope backing updateShortcutIme, onSubtypeChanged and related fire-and-forget coroutines was using a plain Job. A single exception in any of those coroutines would cancel the scope and stop all subsequent subtype lookups for the lifetime of the IME process. Add SupervisorJob() so a single failure cannot tear down the rest of the lookups. * fix(stability): use ContextCompat.registerReceiver with NOT_EXPORTED flag LatinIME.onCreate was using the deprecated registerReceiver(receiver, filter) overload for the ringer mode, package add/remove and user unlocked broadcasts. On Android 13+ this throws SecurityException unless the receiver is registered with an explicit exported flag. Switch the three call sites to ContextCompat.registerReceiver with RECEIVER_NOT_EXPORTED, matching the existing style used for DICTIONARY_DUMP_INTENT_ACTION. The exported flag stays set for the NEW_DICTIONARY_INTENT_ACTION receiver, as documented in the existing comment, because the sender app may not be this one. * fix(settings): update ai provider fields dynamically Align provider preference source and observe changes dynamically to update UI fields immediately. Wrap preferences in key() to prevent Compose state reuse. * feat: add standardOptimised flavor and disable r8 Add standardOptimised flavor to allow non-reproducible optimizations like R8 fullMode and baseline profiles. Turn off R8 fullMode globally to restore reproducibility for standard flavor on F-Droid. Clean APK metadata and restore global V2/V3 signing. * perf: add baseline profile for standardOptimised Add manual wildcard-based precompilation rules in baseline-prof.txt for standardOptimised to optimize startup, typing reaction, and suggestions. Fix dynamic property injection in settings.gradle. * feat: remove standardOptimised package suffix Remove applicationIdSuffix from standardOptimised product flavor to share standard package name (com.leanbitlab.leantype). * fix: dismiss emoji dialog and update wizard status Close ConfirmationDialog on successful emoji dictionary download/load. Invoke onSuccess callback in WelcomeWizard to trigger recomposition and show the checkmark immediately. * Update ar.txt * Update build-debug-apk.yml * arabic-popup-and-harakat-tweak * arabic-popup-and-harakat-tweak V2 * ci: add badge update workflow * chore: update README badges [skip ci] * fix: strip leading v from version tag * chore: update README badges [skip ci] * fix(badges): adjust width and add viewBox attributes to prevent clipping * chore: update README badges [skip ci] * chore(badges): rename download badge label to version * chore: update README badges [skip ci] * fix: prevent duplicate screenshots in clipboard * feat: add toggle for screenshot compression * feat: improve text expander, gestures, and emoji scale fit * chore: update README badges [skip ci] * fix: persist toolbar customizer key toggles * build: bump version to v3.8.5 * feat: toggle dictionaries individually * chore: add changelog for v3.8.5 * fix: split emoji search keyboard layout * chore: update changelog for emoji search fix * added auto detect feature * changed registration flag * chore: update README badges [skip ci] * Update ar.txt * refactor: replace onnxruntime with llamacpp Switches offline proofreader to llamacpp-kotlin GGUF and updates model settings UI to resolve 16 KB page alignment compatibility warnings. * suggestion delete blacklist always. reload blacklist interface add. * blocked words screen add. dictionary screen integration done. settings strings update. * blacklist check case-insensitive. lowercase canonicalization added. user dictionary suggestion leak resolved. * blacklist regex support added. compiled patterns cached. compile-time receiver errors resolved. * SearchScreen remember key fix. filteredItems lambda dependency added. list auto-refresh working. * fix(layout): align Arabic diacritics spacing * chore: update README badges [skip ci] * chore: update README badges [skip ci] * chore: update README badges [skip ci] * chore: update README badges [skip ci] * chore: update README badges [skip ci] * Allow for reasoning models; handle structured content arrays in API responses Parse JSONArray content format with type and text fields. Extract reasoning_content when main content is blank. Fall back to firstChoice text field if content extraction fails. * feat: add regex expander & fix dictionary crash * feat(touchpad): double tap to select word & fix emoji popup preview * feat(offline): add settings for custom sampling & prompt * chore: update gitignore - add .env, .pi/ and remove duplicate * docs: add F-Droid reproducibility delay notice * chore: bump version to v3.8.6 and add changelog * fix(touchpad): always select word on double tap and update docs * feat(touchpad): implement multi-finger gestures and update docs * feat(touchpad): reorganize gestures for intuitive rich text editing layout * feat(touchpad): migrate gestures to 1 and 2 fingers * docs: update features for llama.cpp migration * docs: note model-dependent accuracy in features * fix(touchpad): exit touchpad mode when opening clipboard or emoji * perf(offline): optimize proofreading latency and load times * fix(offline): improve GGUF prompt formatting and output cleaning * fix(offline): truncate model output at template markers and add native stop sequences * fix(offline): implement dynamic target-language-specific few-shot examples for GGUF translation * feat(expander): immediate expand & fix revert * feat: hold toolbar arrow keys to auto-repeat * chore: update README badges [skip ci] * chore: update README badges [skip ci] * chore: update README badges [skip ci] * feat: add toggle for insecure AI connections Allows HTTP local endpoints and self-signed HTTPS connections only when explicitly enabled by the user. * feat: add selective backup and restore * fix: allow same word with different shortcuts * feat: strip spaces before punctuation marks * chore: update README badges [skip ci] * chore: update README badges [skip ci] * chore: update README badges [skip ci] * chore: update README badges [skip ci] * Change default popup key on letter ا in Persian language * chore: update README badges [skip ci] * feat: add handwriting input support * docs: update 3.8.6 changelog * fix: use wildcard mime type for file picker to avoid waydroid crash * fix: clear code cache directory on plugin import/remove * chore: add MD5 hash and size logging for loaded plugin * feat(handwriting): fix crash and dynamic model downloading Move model readiness checks to background thread to prevent main thread blocking exceptions. Add ML Kit client dependencies to standard build flavor for native library alignment. Auto-upgrade toolbar preferences to discover new keys without factory resets. * feat: fix handwriting layout, theming and logic * style: change handwriting toolbar icon color to white * feat: show shortcut overlay on handwriting canvas when plugin missing * fix(ai): resolve offline custom key token loss Prevent token loss and hallucination in local models due to formatting and JNI bugs. * feat(handwriting): add plugin downloader and refine blacklist * build: limit abi filters to arm64-v8a * docs: document handwriting and gguf features * chore(release): bump to 3.10.0 (4000) + changelog * fix(handwriting): don't cancel active keys when hiding inactive handwriting --------- Co-authored-by: LeanBitLab Co-authored-by: iBasim <57762287+iBasim@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Rohan Lodhi <42321434+RohanLodhi@users.noreply.github.com> Co-authored-by: LeanBitLab Co-authored-by: David C Co-authored-by: nugraha-abd <62243267+nugraha-abd@users.noreply.github.com> --- .github/workflows/update-badges.yml | 78 ++ .gitignore | 5 + CHANGELOG.md | 24 + README.md | 19 +- app/build.gradle.kts | 30 +- app/proguard-rules.pro | 3 - app/src/main/AndroidManifest.xml | 34 +- .../handwriting_bottom_row.json | 8 + .../handwriting_bottom_row_with_action.json | 9 + app/src/main/assets/locale_key_texts/ar.txt | 6 +- app/src/main/assets/locale_key_texts/fa.txt | 2 +- .../keyboard/KeyboardActionListenerImpl.kt | 46 +- .../keyboard/keyboard/KeyboardId.java | 6 +- .../keyboard/keyboard/KeyboardLayoutSet.java | 1 + .../keyboard/keyboard/KeyboardSwitcher.java | 65 ++ .../keyboard/keyboard/TouchpadView.java | 163 +++- .../clipboard/ClipboardHistoryView.kt | 20 +- .../keyboard/emoji/EmojiPalettesView.java | 6 +- .../keyboard/internal/KeyboardIconsSet.kt | 4 + .../keyboard_parser/KeyboardParser.kt | 1 + .../keyboard_parser/floris/KeyCode.kt | 4 +- .../keyboard_parser/floris/KeyLabel.kt | 2 + .../main/java/helium314/keyboard/latin/App.kt | 10 +- .../keyboard/latin/DictionaryFacilitator.java | 2 + .../latin/DictionaryFacilitatorImpl.kt | 161 +++- .../helium314/keyboard/latin/LatinIME.java | 39 +- .../keyboard/latin/OtpSuggestionManager.kt | 170 ++++ .../latin/SingleDictionaryFacilitator.kt | 2 + .../latin/handwriting/HandwritingCanvas.kt | 105 +++ .../latin/handwriting/HandwritingLoader.kt | 128 +++ .../handwriting/HandwritingRecognizer.kt | 17 + .../latin/handwriting/HandwritingView.kt | 368 ++++++++ .../keyboard/latin/inputlogic/InputLogic.java | 132 ++- .../keyboard/latin/settings/Defaults.kt | 7 + .../keyboard/latin/settings/Settings.java | 6 + .../latin/settings/SettingsValues.java | 3 + .../latin/suggestions/SuggestionStripView.kt | 19 +- .../keyboard/latin/utils/LayoutType.kt | 3 +- .../keyboard/latin/utils/TextExpanderUtils.kt | 29 +- .../keyboard/latin/utils/ToolbarUtils.kt | 82 +- .../keyboard/settings/SearchScreen.kt | 2 +- .../keyboard/settings/SettingsContainer.kt | 1 + .../keyboard/settings/SettingsNavHost.kt | 5 + .../preferences/BackupRestorePreference.kt | 330 +++++-- .../LoadHandwritingPluginPreference.kt | 250 ++++++ .../settings/screens/AIIntegrationScreen.kt | 2 + .../settings/screens/AdvancedScreen.kt | 142 +-- .../settings/screens/BlockedWordsScreen.kt | 293 ++++++ .../settings/screens/DictionaryScreen.kt | 21 +- .../settings/screens/LibrariesHubScreen.kt | 18 + .../screens/PersonalDictionaryScreen.kt | 28 +- .../settings/screens/TextCorrectionScreen.kt | 20 + .../settings/screens/TextExpanderScreen.kt | 99 ++- .../settings/screens/ToolbarScreen.kt | 13 +- app/src/main/res/drawable/ic_edit.xml | 2 +- app/src/main/res/layout/handwriting_view.xml | 120 +++ .../main/res/layout/main_keyboard_frame.xml | 3 + app/src/main/res/layout/otp_suggestion.xml | 36 + app/src/main/res/values/strings.xml | 43 + .../main/res/xml/network_security_config.xml | 10 + .../keyboard/latin/utils/ProofreadHelper.kt | 15 +- .../keyboard/latin/utils/ProofreadService.kt | 834 +++++++++--------- .../keyboard/latin/utils/ProofreadHelper.kt | 5 + .../keyboard/latin/utils/ProofreadService.kt | 4 +- .../keyboard/latin/utils/ProofreadHelper.kt | 8 + .../keyboard/latin/utils/ProofreadService.kt | 89 +- .../handwriting/HandwritingLoaderTest.kt | 47 + docs/FEATURES.md | 77 +- docs/badges/download.svg | 1 + docs/badges/downloads.svg | 1 + docs/badges/stars.svg | 1 + .../android/en-US/changelogs/4000.txt | 5 + 72 files changed, 3627 insertions(+), 717 deletions(-) create mode 100644 .github/workflows/update-badges.yml create mode 100644 app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row.json create mode 100644 app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row_with_action.json create mode 100644 app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt create mode 100644 app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingCanvas.kt create mode 100644 app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt create mode 100644 app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt create mode 100644 app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt create mode 100644 app/src/main/res/layout/handwriting_view.xml create mode 100644 app/src/main/res/layout/otp_suggestion.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 app/src/test/java/helium314/keyboard/latin/handwriting/HandwritingLoaderTest.kt create mode 100644 docs/badges/download.svg create mode 100644 docs/badges/downloads.svg create mode 100644 docs/badges/stars.svg create mode 100644 fastlane/metadata/android/en-US/changelogs/4000.txt diff --git a/.github/workflows/update-badges.yml b/.github/workflows/update-badges.yml new file mode 100644 index 000000000..a3f5715c1 --- /dev/null +++ b/.github/workflows/update-badges.yml @@ -0,0 +1,78 @@ +name: Update README Badges + +on: + schedule: + - cron: '0 0 * * *' # Midnight UTC + workflow_dispatch: + +permissions: + contents: write + +jobs: + update-badges: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Fetch GitHub stats + id: stats + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + REPO="LeanBitLab/HeliboardL" + + # Latest version + VERSION=$(gh api repos/$REPO/releases/latest --jq '.tag_name' | sed 's/^v//' 2>/dev/null || echo "N/A") + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # Total downloads + DOWNLOADS=$(gh api repos/$REPO/releases --jq '[.[].assets[]?.download_count] | add // 0' 2>/dev/null || echo "0") + echo "downloads=$DOWNLOADS" >> $GITHUB_OUTPUT + + # Stars + STARS=$(gh api repos/$REPO --jq '.stargazers_count' 2>/dev/null || echo "0") + echo "stars=$STARS" >> $GITHUB_OUTPUT + + - name: Generate badge SVGs + env: + VERSION: ${{ steps.stats.outputs.version }} + DOWNLOADS: ${{ steps.stats.outputs.downloads }} + STARS: ${{ steps.stats.outputs.stars }} + run: | + mkdir -p docs/badges + + # Format numbers with commas + DOWNLOADS_FMT=$(printf "%'d" "$DOWNLOADS" 2>/dev/null || echo "$DOWNLOADS") + STARS_FMT=$(printf "%'d" "$STARS" 2>/dev/null || echo "$STARS") + + # Download version badge + cat > docs/badges/download.svg << EOF + VersionVersionv${VERSION}v${VERSION} + EOF + + # Downloads count badge + cat > docs/badges/downloads.svg << EOF + DownloadsDownloads${DOWNLOADS_FMT}${DOWNLOADS_FMT} + EOF + + # Stars badge + cat > docs/badges/stars.svg << EOF + StarsStars${STARS_FMT}${STARS_FMT} + EOF + + echo "Generated: v$VERSION | $DOWNLOADS_FMT downloads | $STARS_FMT stars" + + - name: Update README badge URLs + run: | + # Replace shields.io URLs with local badge paths + sed -i 's|https://img.shields.io/github/v/release/LeanBitLab/HeliboardL?label=Download\&style=for-the-badge\&color=7C4DFF|docs/badges/download.svg|g' README.md + sed -i 's|https://img.shields.io/github/downloads/LeanBitLab/HeliboardL/total?style=for-the-badge\&color=7C4DFF\&label=Downloads|docs/badges/downloads.svg|g' README.md + sed -i 's|https://img.shields.io/github/stars/LeanBitLab/HeliboardL?style=for-the-badge\&color=7C4DFF|docs/badges/stars.svg|g' README.md + + - name: Commit changes + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add docs/badges/ README.md + git diff --staged --quiet || git commit -m "chore: update README badges [skip ci]" + git push diff --git a/.gitignore b/.gitignore index bc03f7232..9e18d66b9 100755 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,8 @@ docs/superpowers/ .agents .kilo/ .antigravitycli/ + +.env + +# AI agent config (personal, not shared) +.pi/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c551bbc4..566105ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,30 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +## [3.10.0] - 2026-06-20 + +### Added +- **Handwriting input** (Standard builds) — write characters on a recognition canvas using a + downloadable plugin, with a dedicated bottom-row layout and a toolbar key. +- **Auto-read OTP from SMS** — a one-time code from an incoming SMS is offered in the suggestion + strip while the keyboard is open; tap to insert. Uses a runtime, opt-in SMS permission. +- **Regex shortcuts in Text Expander** — expansion triggers can be matched by regular expression. + +### Changed +- **Offline AI backend switched from ONNX Runtime to llama.cpp (GGUF).** The Offline build now + loads compact quantized **GGUF** models on-device with configurable sampling + (temperature / top-p / top-k / min-p); it now requires Android 8 (API 26). +- **Touchpad gestures reworked** into a fuller one-/two-finger suite (word select, word-by-word + navigation, space, copy/paste, cut/select-all, undo/redo, hold-to-backspace). Single-finger + double-tap now **selects the word** (previously deleted the selection). +- Release builds now target the **arm64-v8a** ABI only. + +### Upstream +- Merged **LeanBitLab/LeanType v3.8.6** (from v3.8.3) — the source of the handwriting, + llama.cpp/GGUF, touchpad-gesture, and SMS-OTP changes above. Fork identity (LeanTypeDual, distinct + `applicationId`, two-thumb typing, the Gemini standard-AI layer, and the privacy tiers) is + preserved. + ## [3.9.1] - 2026-06-11 ### Fixed diff --git a/README.md b/README.md index dc4901dd7..988fcbe81 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,11 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult ### On top of that — LeanType's AI layer and quality-of-life features - **[🤖 Multi-Provider AI](docs/FEATURES.md#supported-ai-providers)** - Proofread using **Gemini**, **Groq** (Llama 3, Mixtral), or **OpenAI-compatible** providers, with dynamic fetching of the latest models. -- **[🛡️ Offline AI](docs/FEATURES.md#5-offline-proofreading-privacy-focused)** - Private, on-device proofreading and translation using ONNX models (Offline build only). +- **[🛡️ Offline AI (GGUF)](docs/FEATURES.md#5-offline-proofreading-privacy-focused)** - Private, on-device proofreading and translation using local **GGUF models** powered by `llama.cpp` (Offline build only). - **🌐 AI Translation** - Translate selected text using your chosen provider, with a separate model selector. +- **[✍️ Handwriting Input](docs/FEATURES.md#8-handwriting-input)** - Draw characters directly on a handwriting recognition canvas (Standard version, requires [Leantype-Handwriting-Plugin](https://github.com/LeanBitLab/Leantype-Handwriting-Plugin)). - **[🧠 Custom AI Keys](docs/FEATURES.md#4-custom-ai-keys--keywords)** - Assign custom prompts, personas (#editor, #proofread), and labels/tags (themed capsules) to 10 customizable toolbar keys. -- **📝 Text Expander** - Shortcut → expansion with dynamic placeholders (`%clipboard%`, `%day%`, `%time12%`, `%cursor%`, lists), backspace-to-revert, and a guide. +- **📝 Text Expander** - Shortcut → expansion with dynamic placeholders (`%clipboard%`, `%day%`, `%time12%`, `%cursor%`, lists), regex shortcuts, backspace-to-revert, and a guide. - **🧠 Smarter learned words** - *graduated trust* keeps a just-learned word below real-dictionary suggestions until you've used it a few times (no premature autocorrect to half-typed words); flag unknown words to **Add** or **Block** them via a Blocklist screen. - **↩️ Undo word** - a toolbar key that reverts the last committed word back to its suggestion alternatives. - **🗂️ Per-dictionary control** - enable or disable individual built-in and custom dictionaries. @@ -38,7 +39,7 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult - **📸 Screenshot Suggestion & Clipboard** - Recently-taken screenshots are offered in the suggestion strip and saved to clipboard history. - **🔎 Emoji Search** - Search emojis by name. *Requires loading an Emoji Dictionary.* - **⚙️ Enhanced Customization** - Force auto-capitalization, fine-grained haptics, distinct incognito icon, reorganized settings, and more. -- **🔒 Privacy Choices** - Choose **Standard** (opt-in AI), **Offline** (network hard-disabled, offline model), or **Offline Lite** (no AI, ~20 MB). +- **🔒 Privacy Choices** - Choose **Standard** (opt-in AI, handwriting), **Offline** (network hard-disabled, offline GGUF model), or **Offline Lite** (no AI, ~20 MB). @@ -73,20 +74,22 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult +> **⚠️ Note:** F-Droid releases might be delayed or stuck again due to reproducibility verification issues. For the latest version, use GitHub Releases or Obtainium. + ### 📦 Choose Your Version #### 1. Standard Version (`-standard-release.apk`) -* **Features:** Full suite including **AI Proofreading**, **AI Translation**, and **Gesture Library Downloader**. -* **Permissions:** Request `INTERNET` permission (used *only* when you explicitly use AI features). -* **Setup:** Use the built-in downloader for Gesture Typing. Configure AI keys in Settings. +* **Features:** Full suite including **AI Proofreading**, **AI Translation**, **Handwriting Input**, and **Gesture Library Downloader**. +* **Permissions:** Request `INTERNET` permission (used *only* when you explicitly use AI features, download plugins, or update libraries). +* **Setup:** Use the built-in downloader for Gesture Typing and Handwriting Input. Configure AI keys in Settings. #### 2. Offline Version (`-offline-release.apk`) -* **Features:** All UI/UX enhancements and **Offline Neural Proofreading** (ONNX). +* **Features:** All UI/UX enhancements and **Offline Neural Proofreading** (via `llama.cpp` using local **GGUF models**). * **Permissions:** **NO INTERNET PERMISSION**. Guaranteed at OS level. * **Best For:** Privacy purists. * **Manual Setup Required:** * **Gesture Typing:** [Download library manually](https://github.com/erkserkserks/openboard/tree/46fdf2b550035ca69299ce312fa158e7ade36967/app/src/main/jniLibs) and load via *Settings > Gesture typing*. - * **Offline AI:** Download ONNX models and load via *Settings > AI Integration*. 👉 **[See Offline Setup Instructions](docs/FEATURES.md#3-offline-proofreading-privacy-focused)** + * **Offline AI:** Download GGUF models and load via *Settings > Advanced > GGUF Model (.gguf)*. 👉 **[See Offline Setup Instructions](docs/FEATURES.md#5-offline-proofreading-privacy-focused)** #### 3. Offline Lite Version (`-offlinelite-release.apk`) * **Features:** All UI/UX enhancements but **NO AI FEATURES**. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 38fa7f669..8da584599 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,24 +16,22 @@ if (keystorePropertiesFile.exists()) { } android { - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "com.asafmah.leantypedual" minSdk = 21 targetSdk = 35 - versionCode = 3910 - versionName = "3.9.1" + versionCode = 4000 + versionName = "3.10.0" proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") ndk { - abiFilters.addAll(arrayOf("armeabi-v7a", "arm64-v8a")) + abiFilters.addAll(arrayOf("arm64-v8a")) } } - // ONNX Runtime is used instead of llama.cpp native build - flavorDimensions += "privacy" productFlavors { create("standard") { @@ -45,6 +43,7 @@ android { create("offline") { dimension = "privacy" applicationIdSuffix = ".offline" + minSdk = 26 } create("offlinelite") { dimension = "privacy" @@ -141,7 +140,7 @@ android { path = File("src/main/jni/Android.mk") } } -// ndkVersion = "28.0.13004108" + ndkVersion = "28.0.13004108" packaging { jniLibs { @@ -235,8 +234,21 @@ dependencies { "standardOptimisedImplementation"("androidx.security:security-crypto:1.1.0-alpha06") // local llm proofreading (offline) - // ONNX Runtime for T5 encoder-decoder grammar models - "offlineImplementation"("com.microsoft.onnxruntime:onnxruntime-android:1.17.3") + "offlineImplementation"("io.github.ljcamargo:llamacpp-kotlin:0.4.0") + + // Force 16 KB page-aligned version of graphics-path + implementation("androidx.graphics:graphics-path:1.1.0") + + // WorkManager — required by ML Kit Digital Ink plugin (loaded via DexClassLoader). + // ML Kit internally calls WorkManager.getInstance(context) using the host app context, + // so the host app must have WorkManagerInitializer registered in its manifest. + implementation("androidx.work:work-runtime-ktx:2.10.1") + + // ML Kit Digital Ink Recognition — required by the handwriting plugin. + // ML Kit's internal asset manager and native library loader use the host app context, + // so the host app must compile and include the client library resources/libraries. + "standardImplementation"("com.google.mlkit:digital-ink-recognition:19.0.0") + "standardOptimisedImplementation"("com.google.mlkit:digital-ink-recognition:19.0.0") // test testImplementation(kotlin("test")) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 5feeb81f6..50d6ba6d4 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -28,9 +28,6 @@ # Keep java-llama.cpp classes -keep class de.kherud.llama.** { *; } -# ONNX Runtime configurations --dontwarn com.google.protobuf.** --keep class ai.onnxruntime.** { *; } # Fix correct service name -keep class helium314.keyboard.latin.utils.ProofreadService { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9a85c17db..c32d5edf0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,13 +8,15 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only - + + + @@ -27,6 +29,8 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only android:allowBackup="false" android:defaultToDeviceProtectedStorage="true" android:directBootAware="true" + android:networkSecurityConfig="@xml/network_security_config" + android:usesCleartextTraffic="true" tools:remove="android:appComponentFactory" tools:targetApi="p"> @@ -122,6 +126,34 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only android:resource="@xml/provider_paths" /> + + + + + + + + + + + diff --git a/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row.json b/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row.json new file mode 100644 index 000000000..8cb127cfb --- /dev/null +++ b/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row.json @@ -0,0 +1,8 @@ +[ + [ + { "label": "alpha", "width": 0.15 }, + { "label": "clear_handwriting", "width": 0.15 }, + { "label": "space", "width": -1 }, + { "label": "delete", "width": 0.15 } + ] +] diff --git a/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row_with_action.json b/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row_with_action.json new file mode 100644 index 000000000..f83af57a2 --- /dev/null +++ b/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row_with_action.json @@ -0,0 +1,9 @@ +[ + [ + { "label": "alpha", "width": 0.15 }, + { "label": "clear_handwriting", "width": 0.15 }, + { "label": "space", "width": -1 }, + { "label": "delete", "width": 0.15 }, + { "label": "action", "width": 0.15 } + ] +] diff --git a/app/src/main/assets/locale_key_texts/ar.txt b/app/src/main/assets/locale_key_texts/ar.txt index 133219aeb..dfb44df4d 100644 --- a/app/src/main/assets/locale_key_texts/ar.txt +++ b/app/src/main/assets/locale_key_texts/ar.txt @@ -4,15 +4,15 @@ ه ﻫ|ه‍ ج چ ش ڜ -ي ئ ى +ي ى ئ ب پ ل ﻻ|لا ﻷ|لأ ﻹ|لإ ﻵ|لآ -ا !fixedOrder!5 آ ء أ إ ٱ +ا !fixedOrder!5 أ ٱ إ آ ء ك گ ک ى ئ ز ژ و ؤ -punctuation !fixedOrder!7 ٕ|ٕ ٔ|ٔ ْ|ْ ٍ|ٍ ٌ|ٌ ً|ً ّ|ّ ٖ|ٖ ٰ|ٰ ٓ|ٓ ِ|ِ ُ|ُ َ|َ ـــ|ـ +punctuation !fixedOrder!7 ّ◌|ّ ْ◌|ْ َ◌|َ ِ◌|ِ ُ◌|ُ ٍ◌|ٍ ً◌|ً ٌ◌|ٌ ٓ◌|ٓ ٰ◌|ٰ ٕ◌|ٕ ٔ◌|ٔ ٖ◌|ٖ ـــ|ـ « „ “ ” » ‚ ‘ ’ ‹ › diff --git a/app/src/main/assets/locale_key_texts/fa.txt b/app/src/main/assets/locale_key_texts/fa.txt index e84824d74..636293b52 100644 --- a/app/src/main/assets/locale_key_texts/fa.txt +++ b/app/src/main/assets/locale_key_texts/fa.txt @@ -1,7 +1,7 @@ [popup_keys] ه ﻫ|ه‍ هٔ ة ی ئ ي ﯨ|ى -ا !fixedOrder!5 ٱ ء آ أ إ +ا !fixedOrder!5 آ ء ٱ أ إ ت ة ک ك و ؤ diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index 003f05ec9..d347b04c0 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -103,6 +103,14 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp override fun onCodeInput(primaryCode: Int, x: Int, y: Int, isKeyRepeat: Boolean) { when (primaryCode) { + KeyCode.HANDWRITING -> { + if (keyboardSwitcher.isHandwritingShowing) { + keyboardSwitcher.setAlphabetKeyboard() + } else { + keyboardSwitcher.setHandwritingKeyboard() + } + return + } KeyCode.TOGGLE_AUTOCORRECT -> { settings.toggleAutoCorrect() latinIME.onOneShotSpaceActionStateChanged() @@ -587,15 +595,47 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp } } override fun onSingleTap() { - onCodeInput(Constants.CODE_ENTER, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + onCodeInput(Constants.CODE_SPACE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } override fun onDoubleTap() { + onCodeInput(KeyCode.CLIPBOARD_SELECT_WORD, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + override fun onScroll(direction: Int) { + onCodeInput(direction, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + override fun onTwoFingerDoubleTap() { + if (connection.hasSelection()) { + onCodeInput(KeyCode.CLIPBOARD_COPY, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } else { + onCodeInput(KeyCode.CLIPBOARD_PASTE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + } + override fun onThreeFingerTap() { + onCodeInput(KeyCode.CLIPBOARD_PASTE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + override fun onThreeFingerDoubleTap() { + if (connection.hasSelection()) { + onCodeInput(KeyCode.CLIPBOARD_CUT, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } else { + onCodeInput(KeyCode.CLIPBOARD_SELECT_ALL, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + } + override fun onThreeFingerSwipeLeft() { if (connection.hasSelection()) { onCodeInput(KeyCode.DELETE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } else { + onCodeInput(KeyCode.CLIPBOARD_SELECT_WORD, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + onCodeInput(KeyCode.DELETE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } } - override fun onScroll(direction: Int) { - onCodeInput(direction, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + override fun onThreeFingerSwipeRight() { + // Empty for future use + } + override fun onThreeFingerSwipeUp() { + onCodeInput(KeyCode.UNDO, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + override fun onThreeFingerSwipeDown() { + onCodeInput(KeyCode.REDO, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } }) } diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java index 016fe5a2e..7429f5d01 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java @@ -67,6 +67,7 @@ public final class KeyboardId { public static final int ELEMENT_NUMPAD = 28; public static final int ELEMENT_EMOJI_BOTTOM_ROW = 29; public static final int ELEMENT_CLIPBOARD_BOTTOM_ROW = 30; + public static final int ELEMENT_HANDWRITING_BOTTOM_ROW = 31; public final RichInputMethodSubtype mSubtype; public final int mWidth; @@ -208,7 +209,7 @@ public boolean isEmojiKeyboard() { } public boolean isEmojiClipBottomRow() { - return mElementId == ELEMENT_CLIPBOARD_BOTTOM_ROW || mElementId == ELEMENT_EMOJI_BOTTOM_ROW; + return mElementId == ELEMENT_CLIPBOARD_BOTTOM_ROW || mElementId == ELEMENT_EMOJI_BOTTOM_ROW || mElementId == ELEMENT_HANDWRITING_BOTTOM_ROW; } public int imeAction() { @@ -290,6 +291,9 @@ public static String elementIdToName(final int elementId) { case ELEMENT_EMOJI_CATEGORY16 -> "emojiCategory16"; case ELEMENT_CLIPBOARD -> "clipboard"; case ELEMENT_NUMPAD -> "numpad"; + case ELEMENT_EMOJI_BOTTOM_ROW -> "emojiBottomRow"; + case ELEMENT_CLIPBOARD_BOTTOM_ROW -> "clipboardBottomRow"; + case ELEMENT_HANDWRITING_BOTTOM_ROW -> "handwritingBottomRow"; default -> null; }; } diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.java index 572b9efb2..fd3095a40 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.java @@ -227,6 +227,7 @@ public static KeyboardLayoutSet buildEmojiClipBottomRow(final Context context, @ final int height = ResourceUtils.getKeyboardHeight(context.getResources(), Settings.getValues()); builder.setKeyboardGeometry(width, height); builder.setSubtype(RichInputMethodManager.getInstance().getCurrentSubtype()); + builder.setSplitLayoutEnabled(Settings.getValues().mIsSplitKeyboardEnabled); return builder.build(); } diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java index 3fab62c84..a5490480d 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java @@ -42,6 +42,7 @@ import helium314.keyboard.latin.RichInputMethodManager; import helium314.keyboard.latin.RichInputMethodSubtype; import helium314.keyboard.latin.WordComposer; +import helium314.keyboard.latin.handwriting.HandwritingView; import helium314.keyboard.latin.settings.Settings; import helium314.keyboard.latin.settings.SettingsValues; import helium314.keyboard.latin.suggestions.SuggestionStripView; @@ -69,6 +70,7 @@ public final class KeyboardSwitcher implements KeyboardState.SwitchActions { private SuggestionStripView mSuggestionStripView; private LinearLayout mStripContainer; private ClipboardHistoryView mClipboardHistoryView; + private HandwritingView mHandwritingView; private TouchpadView mTouchpadView; private TextView mFakeToastView; private LatinIME mLatinIME; @@ -93,6 +95,10 @@ public static KeyboardSwitcher getInstance() { return sInstance; } + public LatinIME getLatinIME() { + return mLatinIME; + } + private KeyboardSwitcher() { // Intentional empty constructor for singleton. } @@ -355,6 +361,12 @@ private void setMainKeyboardFrame( mSuggestionStripView.setVisibility(stripVisibility); mClipboardHistoryView.setVisibility(View.GONE); mClipboardHistoryView.stopClipboardHistory(); + if (mHandwritingView != null) { + if (mHandwritingView.isShown()) { + mHandwritingView.stopHandwriting(); + } + mHandwritingView.setVisibility(View.GONE); + } if (PointerTracker.sPersistentTouchpadModeActive) { if (mTouchpadView != null) { @@ -378,6 +390,10 @@ public void setEmojiKeyboard() { if (DEBUG_ACTION) { Log.d(TAG, "setEmojiKeyboard"); } + PointerTracker.sPersistentTouchpadModeActive = false; + if (mTouchpadView != null) { + mTouchpadView.setVisibility(View.GONE); + } mMainKeyboardFrame.setVisibility(View.VISIBLE); // The visibility of {@link #mKeyboardView} must be aligned with {@link // #MainKeyboardFrame}. @@ -402,6 +418,10 @@ public void setClipboardKeyboard() { if (DEBUG_ACTION) { Log.d(TAG, "setClipboardKeyboard"); } + PointerTracker.sPersistentTouchpadModeActive = false; + if (mTouchpadView != null) { + mTouchpadView.setVisibility(View.GONE); + } mMainKeyboardFrame.setVisibility(View.VISIBLE); // The visibility of {@link #mKeyboardView} must be aligned with {@link // #MainKeyboardFrame}. @@ -421,6 +441,45 @@ public void setClipboardKeyboard() { mClipboardHistoryView.setVisibility(View.VISIBLE); } + public void setHandwritingKeyboard() { + if (DEBUG_ACTION) { + Log.d(TAG, "setHandwritingKeyboard"); + } + PointerTracker.sPersistentTouchpadModeActive = false; + if (mTouchpadView != null) { + mTouchpadView.setVisibility(View.GONE); + } + mMainKeyboardFrame.setVisibility(View.VISIBLE); + mKeyboardView.setVisibility(View.GONE); + mEmojiTabStripView.setVisibility(View.GONE); + mSuggestionStripView.setVisibility(View.VISIBLE); + mStripContainer.setVisibility(View.VISIBLE); + mClipboardStripScrollView.setVisibility(View.GONE); + mEmojiPalettesView.setVisibility(View.GONE); + mClipboardHistoryView.setVisibility(View.GONE); + + if (mHandwritingView != null) { + final RichInputMethodSubtype subtype = mRichImm.getCurrentSubtype(); + final String language = subtype.getLocale().toLanguageTag(); + mHandwritingView.startHandwriting( + mLatinIME.getCurrentInputEditorInfo(), + mLatinIME.mKeyboardActionListener, + language + ); + mHandwritingView.setVisibility(View.VISIBLE); + } + } + + public boolean isHandwritingShowing() { + return mHandwritingView != null && mHandwritingView.isShown(); + } + + public void clearHandwritingCanvas() { + if (mHandwritingView != null) { + mHandwritingView.clearCanvasAndComposition(); + } + } + @Override public void setNumpadKeyboard() { if (DEBUG_ACTION) { @@ -760,6 +819,8 @@ public View getVisibleKeyboardView() { return mEmojiPalettesView; } else if (isShowingClipboardHistory()) { return mClipboardHistoryView; + } else if (isHandwritingShowing()) { + return mHandwritingView; } return mKeyboardView; } @@ -828,6 +889,7 @@ public View onCreateInputView(@NonNull Context displayContext, final boolean isH mMainKeyboardFrame = mCurrentInputView.findViewById(R.id.main_keyboard_frame); mEmojiPalettesView = mCurrentInputView.findViewById(R.id.emoji_palettes_view); mClipboardHistoryView = mCurrentInputView.findViewById(R.id.clipboard_history_view); + mHandwritingView = mCurrentInputView.findViewById(R.id.handwriting_view); mFakeToastView = mCurrentInputView.findViewById(R.id.fakeToast); mKeyboardViewWrapper = mCurrentInputView.findViewById(R.id.keyboard_view_wrapper); @@ -839,6 +901,9 @@ public View onCreateInputView(@NonNull Context displayContext, final boolean isH mEmojiPalettesView.setKeyboardActionListener(mLatinIME.mKeyboardActionListener); mClipboardHistoryView.setHardwareAcceleratedDrawingEnabled(isHardwareAcceleratedDrawingEnabled); mClipboardHistoryView.setKeyboardActionListener(mLatinIME.mKeyboardActionListener); + if (mHandwritingView != null) { + mHandwritingView.setHardwareAcceleratedDrawingEnabled(isHardwareAcceleratedDrawingEnabled); + } mEmojiTabStripView = mCurrentInputView.findViewById(R.id.emoji_tab_strip); mClipboardStripView = mCurrentInputView.findViewById(R.id.clipboard_strip); mClipboardStripScrollView = mCurrentInputView.findViewById(R.id.clipboard_strip_scroll_view); diff --git a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java index 87460447d..e1151832f 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java @@ -34,6 +34,13 @@ public interface TouchpadListener { void onSingleTap(); void onDoubleTap(); void onScroll(int direction); + void onTwoFingerDoubleTap(); + void onThreeFingerTap(); + void onThreeFingerDoubleTap(); + void onThreeFingerSwipeLeft(); + void onThreeFingerSwipeRight(); + void onThreeFingerSwipeUp(); + void onThreeFingerSwipeDown(); } private TouchpadListener mListener; @@ -52,12 +59,61 @@ public interface TouchpadListener { // Two-finger scroll tracking private boolean mIsTwoFingerScroll; + private float mTwoFingerLastX; private float mTwoFingerLastY; + private float mScrollAccX; private float mScrollAccY; + private float mTwoFingerStartX; + private float mTwoFingerStartY; + private boolean mHasScrolledHorizontally; + private boolean mIsTwoFingerLongPress; + private int mTwoFingerTapCount = 0; + + private final Runnable mTwoFingerLongPressRunnable = new Runnable() { + @Override + public void run() { + if (mIsTwoFingerTap) { + mIsTwoFingerLongPress = true; + if (mListener != null) { + mListener.onThreeFingerSwipeLeft(); + } + postDelayed(this, 150); + } + } + }; // Two-finger tap tracking private boolean mIsTwoFingerTap; private long mTwoFingerDownTime; + private final Runnable mTwoFingerTapRunnable = new Runnable() { + @Override + public void run() { + if (mListener != null) { + if (mTwoFingerTapCount == 1) { + mListener.onSingleTap(); + } else if (mTwoFingerTapCount == 2) { + mListener.onTwoFingerDoubleTap(); + } else if (mTwoFingerTapCount >= 3) { + mListener.onThreeFingerDoubleTap(); + } + } + mTwoFingerTapCount = 0; + } + }; + + // Three-finger tap & swipe tracking + private boolean mIsThreeFingerTap; + private long mThreeFingerDownTime; + private long mLastThreeFingerTapTime = 0; + private final Runnable mThreeFingerTapRunnable = new Runnable() { + @Override + public void run() { + if (mListener != null) mListener.onThreeFingerTap(); + } + }; + private boolean mIsThreeFingerSwipe; + private float mThreeFingerStartX; + private float mThreeFingerStartY; private static final int SCROLL_THRESHOLD = 40; @@ -172,9 +228,14 @@ private void setupTouchSurface() { mTouchpadSurface.setOnTouchListener((v, event) -> { mGestureDetector.onTouchEvent(event); final int pointerCount = event.getPointerCount(); + android.util.Log.i("TouchpadViewRaw", "action=" + MotionEvent.actionToString(event.getActionMasked()) + ", pointers=" + pointerCount); switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: + android.util.Log.i("TouchpadView", "ACTION_DOWN"); + if (v.getParent() != null) { + v.getParent().requestDisallowInterceptTouchEvent(true); + } mLastTouchX = event.getX(); mLastTouchY = event.getY(); mAccX = 0; @@ -184,32 +245,83 @@ private void setupTouchSurface() { return true; case MotionEvent.ACTION_POINTER_DOWN: + android.util.Log.i("TouchpadView", "ACTION_POINTER_DOWN: pointerCount=" + pointerCount); + if (v.getParent() != null) { + v.getParent().requestDisallowInterceptTouchEvent(true); + } if (pointerCount == 2) { mIsTwoFingerScroll = true; mIsTwoFingerTap = true; mTwoFingerDownTime = System.currentTimeMillis(); mIsDragging = false; - mTwoFingerLastY = (event.getY(0) + event.getY(1)) / 2f; + mTwoFingerStartX = (event.getX(0) + event.getX(1)) / 2f; + mTwoFingerStartY = (event.getY(0) + event.getY(1)) / 2f; + mTwoFingerLastX = mTwoFingerStartX; + mTwoFingerLastY = mTwoFingerStartY; + mScrollAccX = 0; mScrollAccY = 0; + mHasScrolledHorizontally = false; + mIsTwoFingerLongPress = false; + + removeCallbacks(mTwoFingerTapRunnable); + postDelayed(mTwoFingerLongPressRunnable, 400); } return true; case MotionEvent.ACTION_MOVE: if (mIsTwoFingerScroll && pointerCount >= 2) { + float midX = (event.getX(0) + event.getX(1)) / 2f; float midY = (event.getY(0) + event.getY(1)) / 2f; - float deltaY = midY - mTwoFingerLastY; - mTwoFingerLastY = midY; - mScrollAccY += deltaY; + float deltaX = midX - mTwoFingerStartX; + float deltaY = midY - mTwoFingerStartY; - while (mScrollAccY >= SCROLL_THRESHOLD) { + float density = getContext().getResources().getDisplayMetrics().density; + + if (Math.abs(midX - mTwoFingerStartX) > 5f * density || Math.abs(midY - mTwoFingerStartY) > 5f * density) { mIsTwoFingerTap = false; - if (mListener != null) mListener.onScroll(KeyCode.ARROW_DOWN); - mScrollAccY -= SCROLL_THRESHOLD; + removeCallbacks(mTwoFingerLongPressRunnable); } - while (mScrollAccY <= -SCROLL_THRESHOLD) { + + float swipeThreshold = 35f * density; + if (!mHasScrolledHorizontally && Math.abs(deltaY) > Math.abs(deltaX) && Math.abs(deltaY) > swipeThreshold) { + mIsTwoFingerScroll = false; mIsTwoFingerTap = false; - if (mListener != null) mListener.onScroll(KeyCode.ARROW_UP); - mScrollAccY += SCROLL_THRESHOLD; + removeCallbacks(mTwoFingerLongPressRunnable); + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); + + if (mListener != null) { + if (deltaY < 0) { + mListener.onThreeFingerSwipeUp(); + } else { + mListener.onThreeFingerSwipeDown(); + } + } + } else { + float lastDeltaX = midX - mTwoFingerLastX; + mTwoFingerLastX = midX; + mTwoFingerLastY = midY; + + mScrollAccX += lastDeltaX; + + while (mScrollAccX >= SCROLL_THRESHOLD) { + mIsTwoFingerTap = false; + removeCallbacks(mTwoFingerLongPressRunnable); + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); + mHasScrolledHorizontally = true; + if (mListener != null) mListener.onScroll(KeyCode.WORD_RIGHT); + mScrollAccX -= SCROLL_THRESHOLD; + } + while (mScrollAccX <= -SCROLL_THRESHOLD) { + mIsTwoFingerTap = false; + removeCallbacks(mTwoFingerLongPressRunnable); + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); + mHasScrolledHorizontally = true; + if (mListener != null) mListener.onScroll(KeyCode.WORD_LEFT); + mScrollAccX += SCROLL_THRESHOLD; + } } } else if (mIsDragging && pointerCount == 1) { float x = event.getX(); @@ -257,11 +369,28 @@ private void setupTouchSurface() { return true; case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: + android.util.Log.i("TouchpadView", "ACTION_UP"); mIsDragging = false; stopEdgeScrolling(); mIsTwoFingerScroll = false; mIsTwoFingerTap = false; + removeCallbacks(mTwoFingerLongPressRunnable); + mIsTwoFingerLongPress = false; + if (mSelectionMode) { + mSelectionMode = false; + applySurfaceColor(); + } + return true; + + case MotionEvent.ACTION_CANCEL: + android.util.Log.i("TouchpadView", "ACTION_CANCEL"); + mIsDragging = false; + mIsTwoFingerScroll = false; + mIsTwoFingerTap = false; + removeCallbacks(mTwoFingerLongPressRunnable); + mIsTwoFingerLongPress = false; + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); if (mSelectionMode) { mSelectionMode = false; applySurfaceColor(); @@ -269,9 +398,19 @@ private void setupTouchSurface() { return true; case MotionEvent.ACTION_POINTER_UP: + android.util.Log.i("TouchpadView", "ACTION_POINTER_UP: pointerCount=" + pointerCount); if (pointerCount == 2) { + removeCallbacks(mTwoFingerLongPressRunnable); + if (mIsTwoFingerLongPress) { + mIsTwoFingerLongPress = false; + mIsTwoFingerScroll = false; + mIsTwoFingerTap = false; + return true; + } if (mIsTwoFingerTap && (System.currentTimeMillis() - mTwoFingerDownTime) < 300) { - if (mListener != null) mListener.onSingleTap(); + mTwoFingerTapCount++; + removeCallbacks(mTwoFingerTapRunnable); + postDelayed(mTwoFingerTapRunnable, 250); } mIsTwoFingerScroll = false; mIsTwoFingerTap = false; diff --git a/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt b/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt index fe33fbf42..8fd6b69ab 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt @@ -34,6 +34,8 @@ import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.utils.ResourceUtils import helium314.keyboard.latin.utils.ToolbarKey import helium314.keyboard.latin.utils.createToolbarKey +import helium314.keyboard.latin.utils.isRepeatableToolbarKey +import helium314.keyboard.latin.utils.RepeatableKeyTouchListener import helium314.keyboard.latin.utils.getCodeForToolbarKey import helium314.keyboard.latin.utils.getCodeForToolbarKeyLongClick import helium314.keyboard.latin.utils.getEnabledClipboardToolbarKeys @@ -202,8 +204,21 @@ class ClipboardHistoryView @JvmOverloads constructor( val clipboardStrip = KeyboardSwitcher.getInstance().clipboardStrip toolbarKeys.forEach { clipboardStrip.addView(it) - it.setOnClickListener(this@ClipboardHistoryView) - it.setOnLongClickListener(this@ClipboardHistoryView) + val tag = it.tag + if (tag is ToolbarKey && isRepeatableToolbarKey(tag)) { + it.setOnTouchListener(RepeatableKeyTouchListener { repeatCount -> + if (repeatCount == 0 || repeatCount % 4 == 0) { + AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(KeyCode.NOT_SPECIFIED, it, HapticEvent.KEY_PRESS) + } + val code = getCodeForToolbarKey(tag) + if (code != KeyCode.UNSPECIFIED) { + keyboardActionListener.onCodeInput(code, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, repeatCount > 0) + } + }) + } else { + it.setOnClickListener(this@ClipboardHistoryView) + it.setOnLongClickListener(this@ClipboardHistoryView) + } colors.setColor(it, ColorType.TOOL_BAR_KEY) it.setBackgroundResource(R.drawable.toolbar_key_background) colors.setColor(it.background, ColorType.TOOL_BAR_EXPAND_KEY_BACKGROUND) @@ -419,6 +434,7 @@ class ClipboardHistoryView @JvmOverloads constructor( private fun setBottomRowLayout(elementId: Int) { val editorInfo = this.editorInfo ?: return val keyboardView = findViewById(R.id.bottom_row_keyboard) + keyboardView.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn) keyboardView.setKeyboardActionListener(this) // Set 'this' as listener to intercept PointerTracker.switchTo(keyboardView) // Use Builder to get correct layout. Match EmojiPalettesView's search-mode setup diff --git a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java index 8eb8995c2..b83c53b10 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java @@ -68,18 +68,15 @@ import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode; import helium314.keyboard.latin.AudioAndHapticFeedbackManager; import helium314.keyboard.latin.SingleDictionaryFacilitator; -import helium314.keyboard.latin.dictionary.Dictionary; import helium314.keyboard.latin.dictionary.DictionaryFactory; import helium314.keyboard.latin.R; import helium314.keyboard.latin.RichInputMethodManager; import helium314.keyboard.latin.RichInputMethodSubtype; -import helium314.keyboard.latin.SingleDictionaryFacilitator; import helium314.keyboard.latin.common.ColorType; import helium314.keyboard.latin.common.Colors; import helium314.keyboard.latin.settings.Settings; import helium314.keyboard.latin.settings.SettingsValues; import helium314.keyboard.latin.suggestions.SuggestionStripView; -import helium314.keyboard.latin.utils.DictionaryInfoUtils; import helium314.keyboard.latin.utils.ResourceUtils; import helium314.keyboard.latin.common.StringUtilsKt; @@ -311,6 +308,7 @@ public void initialize() { // needs to be delayed for access to EmojiTabStrip, w androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL, false)); mSearchAdapter = new EmojiSearchAdapter(emoji -> { mKeyboardActionListener.onTextInput(emoji); + addRecentKey(emoji); // Optionally close search or keep it open for multiple inputs? // restore standard behavior: stop search stopSearchMode(); @@ -721,6 +719,7 @@ public void resetMetaState() { KeyboardLayoutSet kls = builder.build(); bottomRow.setKeyboard(kls.getKeyboard(KeyboardId.ELEMENT_ALPHABET)); + bottomRow.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn); // Focus mSearchBar.requestFocus(); @@ -833,6 +832,7 @@ private void setupBottomRowKeyboard(final EditorInfo editorInfo, if (keyboardView == null || !this.isAttachedToWindow()) { return; } + keyboardView.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn); EditorInfo ei = editorInfo != null ? editorInfo : mEditorInfo; keyboardView.setKeyboardActionListener(keyboardActionListener); diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt index 4fdc0600a..0cd860269 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt @@ -44,6 +44,7 @@ class KeyboardIconsSet private constructor() { } val baseIds = defaultIds.toMutableMap().apply { put(ToolbarKey.CLEAR_CLIPBOARD.name.lowercase(Locale.US), clearClipboardResId) + put("clear_handwriting", R.drawable.ic_close) } val overrideIds = customIconIds(context, prefs) val ids = if (overrideIds.isEmpty()) baseIds else baseIds + overrideIds @@ -166,6 +167,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.FORCE_AUTO_CAP -> R.drawable.ic_force_auto_cap ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.sym_keyboard_clear_clipboard_holo ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close + ToolbarKey.HANDWRITING -> R.drawable.ic_edit ToolbarKey.EMOJI -> R.drawable.sym_keyboard_smiley_holo ToolbarKey.LEFT -> R.drawable.ic_dpad_left ToolbarKey.RIGHT -> R.drawable.ic_dpad_right @@ -248,6 +250,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.FORCE_AUTO_CAP -> R.drawable.ic_force_auto_cap ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.sym_keyboard_clear_clipboard_lxx ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close + ToolbarKey.HANDWRITING -> R.drawable.ic_edit ToolbarKey.EMOJI -> R.drawable.sym_keyboard_smiley_lxx ToolbarKey.LEFT -> R.drawable.ic_dpad_left ToolbarKey.RIGHT -> R.drawable.ic_dpad_right @@ -330,6 +333,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.FORCE_AUTO_CAP -> R.drawable.ic_force_auto_cap_rounded ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.sym_keyboard_clear_clipboard_rounded ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close_rounded + ToolbarKey.HANDWRITING -> R.drawable.ic_edit ToolbarKey.EMOJI -> R.drawable.sym_keyboard_smiley_rounded ToolbarKey.LEFT -> R.drawable.ic_dpad_left_rounded ToolbarKey.RIGHT -> R.drawable.ic_dpad_right_rounded diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt index 083abb1ae..40d4a91c1 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt @@ -57,6 +57,7 @@ class KeyboardParser(private val params: KeyboardParams, private val context: Co LayoutType.NUMPAD_LANDSCAPE else LayoutType.NUMPAD KeyboardId.ELEMENT_EMOJI_BOTTOM_ROW -> LayoutType.EMOJI_BOTTOM KeyboardId.ELEMENT_CLIPBOARD_BOTTOM_ROW -> LayoutType.CLIPBOARD_BOTTOM + KeyboardId.ELEMENT_HANDWRITING_BOTTOM_ROW -> LayoutType.HANDWRITING_BOTTOM else -> LayoutType.MAIN } val baseKeys = LayoutParser.parseLayout(layoutType, params, context) diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt index 82dbcf2b7..ac3227cc9 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt @@ -199,6 +199,8 @@ object KeyCode { const val CUSTOM_AI_9 = -10069 const val CUSTOM_AI_10 = -10070 const val CLIPBOARD_SEARCH = -10071 + const val HANDWRITING = -10074 + const val CLEAR_HANDWRITING = -10075 // Intents @@ -225,7 +227,7 @@ object KeyCode { TIMESTAMP, CTRL_LEFT, CTRL_RIGHT, ALT_LEFT, ALT_RIGHT, META_LEFT, META_RIGHT, SEND_INTENT_ONE, SEND_INTENT_TWO, SEND_INTENT_THREE, INLINE_EMOJI_SEARCH_DONE, META_LOCK, PROOFREAD, TRANSLATE, SHOW_TRANSLATE_LANGUAGES, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, - CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE + CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE, HANDWRITING, CLEAR_HANDWRITING -> this // conversion diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt index 9c213ab0e..abde5f96f 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt @@ -90,6 +90,7 @@ object KeyLabel { fun keyLabelToActualLabel(label: String, params: KeyboardParams): String { val newLabel = when (label) { + "clear_handwriting" -> "!icon/clear_handwriting" SYMBOL_ALPHA -> if (params.mId.isAlphabetKeyboard) params.mLocaleKeyboardInfos.labelSymbol else params.mLocaleKeyboardInfos.labelAlphabet SYMBOL -> params.mLocaleKeyboardInfos.labelSymbol ALPHA -> params.mLocaleKeyboardInfos.labelAlphabet @@ -117,6 +118,7 @@ object KeyLabel { else label } val code = when (label) { // maybe a bit lazy to not assemble the entire string above + "clear_handwriting" -> KeyCode.CLEAR_HANDWRITING SYMBOL_ALPHA -> KeyCode.SYMBOL_ALPHA SYMBOL -> KeyCode.SYMBOL ALPHA -> KeyCode.ALPHA diff --git a/app/src/main/java/helium314/keyboard/latin/App.kt b/app/src/main/java/helium314/keyboard/latin/App.kt index 18e6bcbb5..86ad69460 100644 --- a/app/src/main/java/helium314/keyboard/latin/App.kt +++ b/app/src/main/java/helium314/keyboard/latin/App.kt @@ -2,6 +2,7 @@ package helium314.keyboard.latin import android.app.Application +import androidx.work.Configuration import helium314.keyboard.keyboard.emoji.SupportedEmojis import helium314.keyboard.latin.define.DebugFlags import helium314.keyboard.latin.settings.Defaults @@ -10,7 +11,14 @@ import helium314.keyboard.latin.utils.LayoutUtilsCustom import helium314.keyboard.latin.utils.Log import helium314.keyboard.latin.utils.SubtypeSettings -class App : Application() { +class App : Application(), Configuration.Provider { + + // WorkManager Configuration.Provider — required for ML Kit Digital Ink plugin. + // The plugin is loaded via DexClassLoader and calls WorkManager.getInstance(context) + // internally. This ensures WorkManager can self-initialize via the Application. + override val workManagerConfiguration: Configuration + get() = Configuration.Builder().build() + override fun onCreate() { super.onCreate() DebugFlags.init(this) diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java index 3164003a1..559865ad8 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java @@ -113,6 +113,8 @@ void resetDictionaries( /** permanently blocks the word: removes it from editable dictionaries and adds it to the blacklist */ void blockWord(String word); + void reloadBlacklist(); + void closeDictionaries(); /** main dictionaries are loaded asynchronously after resetDictionaries */ diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index a6195d678..8d69b59a8 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -62,6 +62,7 @@ import java.util.concurrent.TimeUnit @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class DictionaryFacilitatorImpl : DictionaryFacilitator { private var mPrefs: SharedPreferences? = null + private var mContext: Context? = null private var mEnabledDictionariesState: Map = emptyMap() private var dictionaryGroups = listOf(DictionaryGroup()) @@ -78,13 +79,15 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { private var changeFrom = "" private var changeTo = "" + private var mLoadedSuggestEmojis: Boolean = false + private var mLoadedEmojiDictExists: Boolean = false + private val SPELLING_DICTIONARY_TYPES = arrayOf( Dictionary.TYPE_MAIN, Dictionary.TYPE_CONTACTS, Dictionary.TYPE_APPS, Dictionary.TYPE_USER_HISTORY ) - // Caches for spell checking word validity private var mValidSpellingWordReadCache: LruCache? = LruCache(500) private var mValidSpellingWordWriteCache: LruCache? = LruCache(500) @@ -144,6 +147,12 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { return false } } + val ctx = mContext ?: return false + val currentSuggestEmojis = Settings.getValues().mSuggestEmojis + val currentEmojiDictExists = locales.any { helium314.keyboard.latin.utils.DictionaryInfoUtils.getCachedDictForLocaleAndType(it, Dictionary.TYPE_EMOJI, ctx) != null } + if (currentSuggestEmojis != mLoadedSuggestEmojis || currentEmojiDictExists != mLoadedEmojiDictExists) { + return false + } val dictGroup = dictionaryGroups[0] // settings are the same for all groups return contacts == dictGroup.hasDict(Dictionary.TYPE_CONTACTS) && apps == dictGroup.hasDict(Dictionary.TYPE_APPS) @@ -165,6 +174,7 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { listener: DictionaryInitializationListener? ) { Log.i(TAG, "resetDictionaries, force reloading main dictionary: $forceReloadMainDictionary") + mContext = context.applicationContext val prefs = context.prefs() mPrefs = prefs mEnabledDictionariesState = prefs.all.filterKeys { it.startsWith("pref_dict_enabled_") } @@ -237,7 +247,11 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { // create new or re-use already loaded main dict val mainDict: Dictionary? - if (forceReload || oldDictGroupForLocale == null + val currentSuggestEmojis = Settings.getValues().mSuggestEmojis + val currentEmojiDictExists = helium314.keyboard.latin.utils.DictionaryInfoUtils.getCachedDictForLocaleAndType(locale, Dictionary.TYPE_EMOJI, context) != null + val forceReloadMain = forceReload || (currentSuggestEmojis != mLoadedSuggestEmojis) || (currentEmojiDictExists != mLoadedEmojiDictExists) + + if (forceReloadMain || oldDictGroupForLocale == null || !oldDictGroupForLocale.hasDict(Dictionary.TYPE_MAIN) ) { mainDict = null // null main dicts will be loaded later in asyncReloadUninitializedMainDictionaries @@ -276,6 +290,8 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { scope.launch { try { val useEmojiDict = Settings.getValues().mSuggestEmojis + mLoadedSuggestEmojis = useEmojiDict + mLoadedEmojiDictExists = locales.any { helium314.keyboard.latin.utils.DictionaryInfoUtils.getCachedDictForLocaleAndType(it, Dictionary.TYPE_EMOJI, context) != null } val dictGroupsWithNewMainDict = locales.mapNotNull { val dictionaryGroup = findDictionaryGroupWithLocale(dictionaryGroups, it) if (dictionaryGroup == null) { @@ -711,6 +727,12 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { } } + override fun reloadBlacklist() { + for (dictionaryGroup in dictionaryGroups) { + dictionaryGroup.reloadBlacklist() + } + } + override fun addToUserDictionary(word: String) { if (word.isEmpty()) return val group = currentlyPreferredDictionaryGroup @@ -874,6 +896,12 @@ private class DictionaryGroup( /** Removes a word from all dictionaries in this group. If the word is in a read-only dictionary, it is blacklisted. */ fun removeWord(word: String) { + addToBlacklist(word) + val lowercase = word.lowercase(locale) + if (word != lowercase) { + addToBlacklist(lowercase) + } + // remove from user history getSubDict(Dictionary.TYPE_USER_HISTORY)?.removeUnigramEntryDynamically(word) @@ -883,27 +911,12 @@ private class DictionaryGroup( val contactsDict = getSubDict(Dictionary.TYPE_CONTACTS) if (contactsDict != null && contactsDict.isInDictionary(word)) { contactsDict.removeUnigramEntryDynamically(word) // will be gone until next reload of dict - addToBlacklist(word) - return } val appsDict = getSubDict(Dictionary.TYPE_APPS) if (appsDict != null && appsDict.isInDictionary(word)) { appsDict.removeUnigramEntryDynamically(word) // will be gone until next reload of dict - addToBlacklist(word) - return - } - val mainDict = mainDict - if (mainDict != null && mainDict.isValidWord(word)) { - addToBlacklist(word) - return - } - - val lowercase = word.lowercase(locale) - if (mainDict != null && mainDict.isValidWord(lowercase)) { - addToBlacklist(lowercase) - return } // The word was in no read-only dictionary (main/contacts/apps) — it only lived in the mutable @@ -978,44 +991,134 @@ private class DictionaryGroup( else null } + @Volatile + private var compiledBlacklistPatterns: List = emptyList() + + private fun rebuildCompiledPatterns() { + compiledBlacklistPatterns = blacklist.map { pattern -> + try { + Regex(pattern, RegexOption.IGNORE_CASE) + } catch (e: Exception) { + Regex(Regex.escape(pattern), RegexOption.IGNORE_CASE) + } + } + } + private val blacklist = hashSetOf().apply { - if (blacklistFile?.isFile != true) return@apply + val file = blacklistFile + if (file == null) return@apply scope.launch { synchronized(blacklistLock) { try { - addAll(blacklistFile.readLines()) + val loadedWords = mutableSetOf() + if (file.isFile) { + loadedWords.addAll(file.readLines().map { it.lowercase(locale) }) + } + val langTag = locale.toLanguageTag() + if (locale.language.isNotEmpty() && locale.language != langTag) { + val baseFile = File(file.parentFile, "${locale.language}.txt") + if (baseFile.isFile) { + loadedWords.addAll(baseFile.readLines().map { it.lowercase(locale) }) + } + } + addAll(loadedWords) + rebuildCompiledPatterns() } catch (e: IOException) { - Log.e(TAG, "Exception while trying to read blacklist from ${blacklistFile.name}", e) + Log.e(TAG, "Exception while trying to read blacklist from ${file.name}", e) } } } } - fun isBlacklisted(word: String) = blacklist.contains(word) + fun isBlacklisted(word: String): Boolean { + val patterns = compiledBlacklistPatterns + return patterns.any { it.matches(word) } + } fun addToBlacklist(word: String) { - if (!blacklist.add(word) || blacklistFile == null) return + val lowercase = word.lowercase(locale) + synchronized(blacklistLock) { + if (!blacklist.add(lowercase)) return + rebuildCompiledPatterns() + } + val file = blacklistFile ?: return scope.launch { synchronized(blacklistLock) { try { - if (blacklistFile.isDirectory) blacklistFile.delete() - blacklistFile.appendText("$word\n") + if (file.isDirectory) file.delete() + file.appendText("$lowercase\n") + val langTag = locale.toLanguageTag() + if (locale.language.isNotEmpty() && locale.language != langTag) { + val baseFile = File(file.parentFile, "${locale.language}.txt") + if (baseFile.isDirectory) baseFile.delete() + baseFile.appendText("$lowercase\n") + } } catch (e: IOException) { - Log.e(TAG, "Exception while trying to add word \"$word\" to blacklist ${blacklistFile.name}", e) + Log.e(TAG, "Exception while trying to add word \"$lowercase\" to blacklist ${file.name}", e) } } } } fun removeFromBlacklist(word: String) { - if (!blacklist.remove(word) || blacklistFile == null) return + val lowercase = word.lowercase(locale) + synchronized(blacklistLock) { + if (!blacklist.remove(lowercase)) return + rebuildCompiledPatterns() + } + val file = blacklistFile ?: return scope.launch { synchronized(blacklistLock) { try { - val newLines = blacklistFile.readLines().filterNot { it == word } - blacklistFile.writeText(newLines.joinToString("\n")) + val files = mutableListOf(file) + val langTag = locale.toLanguageTag() + if (locale.language.isNotEmpty() && locale.language != langTag) { + files.add(File(file.parentFile, "${locale.language}.txt")) + } + for (f in files) { + if (f.isFile) { + val lines = f.readLines() + val newLines = lines.filterNot { it.lowercase(locale) == lowercase } + if (newLines.size != lines.size) { + f.writeText(newLines.joinToString("\n") + if (newLines.isEmpty()) "" else "\n") + } + } + } + } catch (e: IOException) { + Log.e(TAG, "Exception while trying to remove word \"$word\" from blacklist ${file.name}", e) + } + } + } + } + + fun reloadBlacklist() { + val file = blacklistFile + if (file == null) { + synchronized(blacklistLock) { + blacklist.clear() + rebuildCompiledPatterns() + } + return + } + scope.launch { + synchronized(blacklistLock) { + try { + blacklist.clear() + val loadedWords = mutableSetOf() + if (file.isFile) { + loadedWords.addAll(file.readLines().map { it.lowercase(locale) }) + } + val langTag = locale.toLanguageTag() + if (locale.language.isNotEmpty() && locale.language != langTag) { + val baseFile = File(file.parentFile, "${locale.language}.txt") + if (baseFile.isFile) { + loadedWords.addAll(baseFile.readLines().map { it.lowercase(locale) }) + } + } + blacklist.addAll(loadedWords) + rebuildCompiledPatterns() } catch (e: IOException) { - Log.e(TAG, "Exception while trying to remove word \"$word\" to blacklist ${blacklistFile.name}", e) + Log.e(TAG, "Exception while trying to read blacklist from ${file.name}", e) } } } diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index a670093a7..b2577c3c4 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -182,6 +182,7 @@ public void onReceive(Context context, Intent intent) { private GestureConsumer mGestureConsumer = GestureConsumer.NULL_GESTURE_CONSUMER; private final ClipboardHistoryManager mClipboardHistoryManager = new ClipboardHistoryManager(this); + private final OtpSuggestionManager mOtpSuggestionManager = new OtpSuggestionManager(this); private FloatingKeyboardManager mFloatingKeyboardManager; @@ -711,6 +712,7 @@ public void onDestroy() { mFloatingKeyboardManager.destroy(); } mClipboardHistoryManager.onDestroy(); + mOtpSuggestionManager.stop(); mDictionaryFacilitator.closeDictionaries(); mSettings.onDestroy(); unregisterReceiver(mRingerModeChangeReceiver); @@ -915,6 +917,7 @@ private void onStartInputInternal(final EditorInfo editorInfo, final boolean res void onStartInputViewInternal(final EditorInfo editorInfo, final boolean restarting) { super.onStartInputView(editorInfo, restarting); + helium314.keyboard.latin.utils.ProofreadHelper.preloadModel(this); mDictionaryFacilitator.onStartInput(); // Switch to the null consumer to handle cases leading to early exit below, for @@ -1093,6 +1096,10 @@ void onStartInputViewInternal(final EditorInfo editorInfo, final boolean restart if (TRACE) Debug.startMethodTracing("/data/trace/latinime"); + + // Listen for incoming SMS OTPs only while the keyboard is shown, and only if the + // user has opted in and granted the permission (handled inside the manager). + mOtpSuggestionManager.start(); } @Override @@ -1130,6 +1137,7 @@ void onFinishInputInternal() { void onFinishInputViewInternal(final boolean finishingInput) { super.onFinishInputView(finishingInput); Log.i(TAG, "onFinishInputView"); + mOtpSuggestionManager.stop(); cleanupInternalStateForFinishInput(); } @@ -1702,9 +1710,12 @@ public void pickSuggestionManually(final SuggestedWordInfo suggestionInfo) { mKeyboardSwitcher.getKeyboardShiftMode(), mHandler); updateStateAfterInputTransaction(completeInputTransaction); + if (mKeyboardSwitcher.isHandwritingShowing()) { + mKeyboardSwitcher.clearHandwritingCanvas(); + } - if (suggestionInfo.mSourceDict != null && helium314.keyboard.latin.dictionary.Dictionary.TYPE_EMOJI - .equals(suggestionInfo.mSourceDict.mDictType)) { + if (suggestionInfo.isEmoji() || (suggestionInfo.mSourceDict != null && helium314.keyboard.latin.dictionary.Dictionary.TYPE_EMOJI + .equals(suggestionInfo.mSourceDict.mDictType))) { final helium314.keyboard.keyboard.emoji.EmojiPalettesView emojiView = mKeyboardSwitcher .getEmojiPalettesView(); if (emojiView != null) { @@ -1718,6 +1729,21 @@ public void pickSuggestionManually(final SuggestedWordInfo suggestionInfo) { * in suggestion strip. * returns whether a clipboard suggestion has been set. */ + /** + * Checks if a recent SMS OTP suggestion is available. If so, it is set in the suggestion strip. + * Returns whether an OTP suggestion has been set. + */ + public boolean tryShowOtpSuggestion() { + if (!hasSuggestionStripView()) return false; + final View otpView = mOtpSuggestionManager.getOtpSuggestionView(mSuggestionStripView); + if (otpView != null) { + // false: the OTP chip layout already has its own close button (wired in the manager) + mSuggestionStripView.setExternalSuggestionView(otpView, false); + return true; + } + return false; + } + public boolean tryShowClipboardSuggestion() { final View clipboardView = mClipboardHistoryManager.getClipboardSuggestionView(getCurrentInputEditorInfo(), mSuggestionStripView); @@ -1743,8 +1769,8 @@ public boolean tryShowClipboardSuggestion() { @Override public void setNeutralSuggestionStrip() { final SettingsValues currentSettings = mSettings.getCurrent(); - if (tryShowClipboardSuggestion()) { - // clipboard suggestion has been set + if (tryShowOtpSuggestion() || tryShowClipboardSuggestion()) { + // an external (OTP or clipboard) suggestion has been set if (hasSuggestionStripView() && currentSettings.mAutoHideToolbar) mSuggestionStripView.setToolbarVisibility(false); return; @@ -1781,6 +1807,11 @@ public void onOneShotSpaceActionStateChanged() { @Override public void removeSuggestion(final String word) { mDictionaryFacilitator.removeWord(word); + mInputLogic.getSuggest().clearNextWordSuggestionsCache(); + } + + public DictionaryFacilitator getDictionaryFacilitator() { + return mDictionaryFacilitator; } @Override diff --git a/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt b/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt new file mode 100644 index 000000000..669c36482 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: GPL-3.0-only + +package helium314.keyboard.latin + +import android.Manifest +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Handler +import android.os.Looper +import android.provider.Telephony +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import helium314.keyboard.event.HapticEvent +import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode +import helium314.keyboard.latin.common.ColorType +import helium314.keyboard.latin.databinding.OtpSuggestionBinding +import helium314.keyboard.latin.permissions.PermissionsUtil +import helium314.keyboard.latin.utils.Log +import helium314.keyboard.latin.utils.ToolbarKey + +/** + * Optional, opt-in helper that surfaces one-time passcodes (OTPs) from incoming SMS as a + * suggestion-strip chip the user can tap to insert (similar to the clipboard/screenshot + * suggestions, see [ClipboardHistoryManager.getClipboardSuggestionView]). + * + * Privacy: this never reads the existing SMS inbox. A [BroadcastReceiver] is registered only + * while the keyboard input view is shown and only when the feature is enabled and the + * RECEIVE_SMS permission has been granted, so the keyboard only ever sees messages that arrive + * while the user is actively typing. + */ +class OtpSuggestionManager(private val latinIME: LatinIME) { + + private val mainHandler = Handler(Looper.getMainLooper()) + private var otpSuggestionView: View? = null + private var dontShowCurrentSuggestion = false + + private var latestOtp: String? = null + private var latestOtpTimestamp = 0L + + private var isRegistered = false + private val smsReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return + val body = try { + Telephony.Sms.Intents.getMessagesFromIntent(intent) + ?.joinToString(separator = "") { it.messageBody ?: it.displayMessageBody ?: "" } + ?: return + } catch (e: Exception) { + Log.w(TAG, "Failed to read incoming SMS", e) + return + } + val otp = extractOtp(body) ?: return + latestOtp = otp + latestOtpTimestamp = System.currentTimeMillis() + dontShowCurrentSuggestion = false + // Refresh the strip on the main thread so the chip appears immediately, + // mirroring the screenshot-observer path in ClipboardHistoryManager. + mainHandler.post { + if (latinIME.isInputViewShown) latinIME.setNeutralSuggestionStrip() + } + } + } + + /** Register the SMS receiver if the feature is enabled and the permission is granted. Idempotent. */ + fun start() { + if (isRegistered) return + if (!latinIME.mSettings.current.mAutoReadOtp) return + if (!PermissionsUtil.checkAllPermissionsGranted(latinIME, Manifest.permission.RECEIVE_SMS)) return + try { + ContextCompat.registerReceiver( + latinIME, + smsReceiver, + IntentFilter(Telephony.Sms.Intents.SMS_RECEIVED_ACTION), + // EXPORTED is required: SMS_RECEIVED is delivered by the system/telephony process + // (an external sender), so a NOT_EXPORTED receiver never receives it. This is safe + // because SMS_RECEIVED is a protected broadcast that only the system can send. + ContextCompat.RECEIVER_EXPORTED + ) + isRegistered = true + } catch (e: Exception) { + Log.w(TAG, "Could not register SMS receiver", e) + } + } + + /** Unregister the receiver. Idempotent. Called when the input view is hidden or the IME is destroyed. */ + fun stop() { + if (!isRegistered) return + try { + latinIME.unregisterReceiver(smsReceiver) + } catch (e: Exception) { + Log.w(TAG, "Could not unregister SMS receiver", e) + } + isRegistered = false + } + + /** + * Build the OTP suggestion chip if a recent code is available, else null. + * Called from [LatinIME.tryShowOtpSuggestion]. + */ + fun getOtpSuggestionView(parent: ViewGroup?): View? { + otpSuggestionView = null + if (parent == null) return null + if (!latinIME.mSettings.current.mAutoReadOtp) return null + if (dontShowCurrentSuggestion) return null + val otp = latestOtp ?: return null + if (System.currentTimeMillis() - latestOtpTimestamp > RECENT_OTP_MILLIS) return null + + val binding = OtpSuggestionBinding.inflate(LayoutInflater.from(latinIME), parent, false) + val textView = binding.otpSuggestionText + latinIME.mSettings.getCustomTypeface()?.let { textView.typeface = it } + textView.text = otp + val icon = latinIME.mKeyboardSwitcher.keyboard?.mIconsSet?.getIconDrawable(ToolbarKey.NUMPAD.name.lowercase()) + textView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null) + textView.setOnClickListener { + dontShowCurrentSuggestion = true + latinIME.onTextInput(otp) + AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(KeyCode.NOT_SPECIFIED, it, HapticEvent.KEY_PRESS) + binding.root.isGone = true + } + val closeButton = binding.otpSuggestionClose + closeButton.setImageDrawable(latinIME.mKeyboardSwitcher.keyboard?.mIconsSet?.getIconDrawable(ToolbarKey.CLOSE_HISTORY.name.lowercase())) + closeButton.setOnClickListener { removeOtpSuggestion() } + + val colors = latinIME.mSettings.current.mColors + textView.setTextColor(colors.get(ColorType.KEY_TEXT)) + icon?.let { colors.setColor(it, ColorType.KEY_ICON) } + colors.setColor(closeButton, ColorType.REMOVE_SUGGESTION_ICON) + colors.setBackground(binding.root, ColorType.CLIPBOARD_SUGGESTION_BACKGROUND) + + otpSuggestionView = binding.root + return otpSuggestionView + } + + private fun removeOtpSuggestion() { + dontShowCurrentSuggestion = true + val view = otpSuggestionView ?: return + if (view.parent != null && !view.isGone) { + latinIME.setNeutralSuggestionStrip() + latinIME.mHandler.postResumeSuggestions(false) + } + view.isGone = true + } + + /** + * Extract an OTP from an SMS body. Keyword-gated to limit false positives: a 4-8 digit group is + * only treated as a code when the message mentions a code-like keyword, or when it is the single + * such group in the message. + */ + private fun extractOtp(body: String): String? { + if (body.isBlank()) return null + val groups = codeRegex.findAll(body).map { it.value }.toList() + if (groups.isEmpty()) return null + return if (otpKeywordRegex.containsMatchIn(body) || groups.size == 1) groups.first() else null + } + + companion object { + private const val TAG = "OtpSuggestionManager" + private const val RECENT_OTP_MILLIS = 60 * 1000L // OTP chip is offered for 60s after arrival + private val codeRegex = Regex("\\b\\d{4,8}\\b") + private val otpKeywordRegex = Regex( + "otp|code|passcode|password|pin|verification|verify|one[- ]?time|2fa|auth", + RegexOption.IGNORE_CASE + ) + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt b/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt index a27bb6e60..cf7176dc7 100644 --- a/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt +++ b/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt @@ -134,6 +134,8 @@ class SingleDictionaryFacilitator(private val dict: Dictionary) : DictionaryFaci override fun blockWord(word: String) {} + override fun reloadBlacklist() {} + override fun clearUserHistoryDictionary(context: Context) {} override fun localesAndConfidences(): String? = null diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingCanvas.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingCanvas.kt new file mode 100644 index 000000000..41a30ac5f --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingCanvas.kt @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.handwriting + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View + +class HandwritingCanvas @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private val strokePaint = Paint().apply { + color = 0xFF3F51B5.toInt() // Default blue, will be overridden by theme later + style = Paint.Style.STROKE + strokeWidth = 10f + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + isAntiAlias = true + } + + private val path = Path() + private val strokes = mutableListOf() + private var currentStroke = mutableListOf() + private var startTime: Long = 0 + private var isRecognitionDone = false + + private val mainHandler = Handler(Looper.getMainLooper()) + private val recognitionTimeout = 700L + private val recognizeRunnable = Runnable { + isRecognitionDone = true + onRecognitionTriggered?.invoke(ArrayList(strokes)) + } + + var onRecognitionTriggered: ((List) -> Unit)? = null + var onStrokeStarted: (() -> Unit)? = null + + fun setStrokeColor(color: Int) { + strokePaint.color = color + invalidate() + } + + fun clear() { + mainHandler.removeCallbacks(recognizeRunnable) + path.reset() + strokes.clear() + currentStroke.clear() + isRecognitionDone = false + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawPath(path, strokePaint) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + val x = event.x + val y = event.y + val time = event.eventTime + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + mainHandler.removeCallbacks(recognizeRunnable) + if (isRecognitionDone) { + onStrokeStarted?.invoke() + isRecognitionDone = false + } + path.moveTo(x, y) + startTime = time + currentStroke.clear() + currentStroke.add(x) + currentStroke.add(y) + currentStroke.add(0f) // Relative time start + invalidate() + } + MotionEvent.ACTION_MOVE -> { + path.lineTo(x, y) + currentStroke.add(x) + currentStroke.add(y) + currentStroke.add((time - startTime).toFloat()) + invalidate() + } + MotionEvent.ACTION_UP -> { + path.lineTo(x, y) + currentStroke.add(x) + currentStroke.add(y) + currentStroke.add((time - startTime).toFloat()) + strokes.add(currentStroke.toFloatArray()) + currentStroke.clear() + invalidate() + + mainHandler.postDelayed(recognizeRunnable, recognitionTimeout) + } + } + return true + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt new file mode 100644 index 000000000..9731bd67d --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.handwriting + +import android.content.Context +import android.net.Uri +import dalvik.system.DexClassLoader +import helium314.keyboard.latin.utils.Log +import helium314.keyboard.latin.utils.prefs +import java.io.File + +object HandwritingLoader { + private const val PLUGIN_FILENAME = "handwriting_plugin.apk" + private const val PLUGIN_CLASS_NAME = "helium314.keyboard.handwriting.plugin.HandwritingRecognizerImpl" + private const val PREF_HAS_PLUGIN = "pref_handwriting_has_plugin" + + private var activeRecognizer: HandwritingRecognizer? = null + + fun getRecognizer(context: Context): HandwritingRecognizer? { + if (activeRecognizer != null) return activeRecognizer + if (!hasPlugin(context)) return null + + val apkFile = File(context.filesDir, PLUGIN_FILENAME) + if (!apkFile.exists()) { + context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, false).apply() + return null + } + apkFile.setReadOnly() + + try { + val md5 = java.security.MessageDigest.getInstance("MD5") + val bytes = apkFile.readBytes() + val hash = md5.digest(bytes).joinToString("") { "%02x".format(it) } + Log.i("HandwritingLoader", "Loaded plugin APK path: ${apkFile.absolutePath}, size: ${bytes.size}, md5: $hash") + } catch (e: Exception) { + Log.e("HandwritingLoader", "Failed to calculate MD5", e) + } + + try { + val classLoader = DexClassLoader( + apkFile.absolutePath, + context.codeCacheDir.absolutePath, + null, + context.classLoader + ) + val clazz = classLoader.loadClass(PLUGIN_CLASS_NAME) + val recognizer = clazz.getDeclaredConstructor().newInstance() as HandwritingRecognizer + recognizer.init(context) + activeRecognizer = recognizer + return recognizer + } catch (e: Exception) { + Log.e("HandwritingLoader", "Failed to load handwriting plugin", e) + } + return null + } + + fun hasPlugin(context: Context): Boolean { + return context.prefs().getBoolean(PREF_HAS_PLUGIN, false) + } + + fun getPluginVersion(context: Context): String? { + val apkFile = File(context.filesDir, PLUGIN_FILENAME) + if (!apkFile.exists()) return null + return try { + val info = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0) + info?.versionName + } catch (e: Exception) { + null + } + } + + + fun importPlugin(context: Context, uri: Uri): Boolean { + try { + try { + context.codeCacheDir.deleteRecursively() + } catch (_: Exception) {} + + val apkFile = File(context.filesDir, PLUGIN_FILENAME) + if (apkFile.exists()) { + apkFile.delete() + } + context.contentResolver.openInputStream(uri)?.use { input -> + apkFile.outputStream().use { output -> + input.copyTo(output) + } + } + apkFile.setReadOnly() + + // Verify the plugin loads successfully + val classLoader = DexClassLoader( + apkFile.absolutePath, + context.codeCacheDir.absolutePath, + null, + context.classLoader + ) + val clazz = classLoader.loadClass(PLUGIN_CLASS_NAME) + val recognizer = clazz.getDeclaredConstructor().newInstance() as HandwritingRecognizer + recognizer.init(context) + + context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, true).apply() + activeRecognizer = recognizer + return true + } catch (e: Exception) { + Log.e("HandwritingLoader", "Failed to import plugin APK", e) + // Cleanup on failure + try { + File(context.filesDir, PLUGIN_FILENAME).delete() + } catch (_: Exception) {} + try { + context.codeCacheDir.deleteRecursively() + } catch (_: Exception) {} + context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, false).apply() + activeRecognizer = null + } + return false + } + + fun removePlugin(context: Context) { + try { + File(context.filesDir, PLUGIN_FILENAME).delete() + } catch (_: Exception) {} + try { + context.codeCacheDir.deleteRecursively() + } catch (_: Exception) {} + context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, false).apply() + activeRecognizer = null + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt new file mode 100644 index 000000000..308a0ecdf --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.handwriting + +import android.content.Context + +interface ModelDownloadListener { + fun onProgress(progress: Float) + fun onComplete(success: Boolean) +} + +interface HandwritingRecognizer { + fun init(context: Context) + fun setLanguage(language: String): Boolean + fun isLanguageReady(language: String): Boolean + fun downloadModel(language: String, listener: ModelDownloadListener) + fun recognize(strokes: List): List? +} diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt new file mode 100644 index 000000000..39a6227e3 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.handwriting + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import android.graphics.drawable.GradientDrawable +import helium314.keyboard.keyboard.KeyboardActionListener +import helium314.keyboard.keyboard.KeyboardId +import helium314.keyboard.keyboard.KeyboardLayoutSet +import helium314.keyboard.keyboard.MainKeyboardView +import helium314.keyboard.keyboard.PointerTracker +import helium314.keyboard.latin.AudioAndHapticFeedbackManager +import helium314.keyboard.latin.R +import helium314.keyboard.latin.RichInputConnection +import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode +import helium314.keyboard.latin.common.Constants +import helium314.keyboard.latin.common.ColorType +import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.utils.Log +import helium314.keyboard.latin.SuggestedWords +import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo +import helium314.keyboard.latin.dictionary.Dictionary +import android.view.inputmethod.EditorInfo +import helium314.keyboard.keyboard.KeyboardSwitcher +import helium314.keyboard.event.HapticEvent +import helium314.keyboard.latin.RichInputMethodManager +import helium314.keyboard.latin.utils.LanguageOnSpacebarUtils + +class HandwritingView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr), KeyboardActionListener { + + private lateinit var languageLabel: TextView + private lateinit var clearButton: ImageButton + private lateinit var canvas: HandwritingCanvas + private lateinit var bottomRowKeyboard: MainKeyboardView + + private var keyboardActionListener: KeyboardActionListener? = null + private var editorInfo: EditorInfo? = null + private var currentLanguage: String = "" + + private var currentComposingText = "" + + override fun onFinishInflate() { + super.onFinishInflate() + languageLabel = findViewById(R.id.handwriting_language_label) + clearButton = findViewById(R.id.handwriting_clear_button) + canvas = findViewById(R.id.handwriting_canvas) + bottomRowKeyboard = findViewById(R.id.handwriting_bottom_row_keyboard) + + clearButton.setOnClickListener { + clearCanvasAndComposition() + } + + canvas.onStrokeStarted = { + commitCurrentComposition() + canvas.clear() + } + + canvas.onRecognitionTriggered = { strokes -> + performRecognition(strokes) + canvas.clear() + } + } + + fun startHandwriting( + editorInfo: EditorInfo, + keyboardActionListener: KeyboardActionListener, + language: String + ) { + this.editorInfo = editorInfo + this.keyboardActionListener = keyboardActionListener + this.currentLanguage = language + + val colors = Settings.getValues().mColors + val toolbar = findViewById(R.id.handwriting_toolbar) + if (toolbar != null) { + colors.setBackground(toolbar, ColorType.MAIN_BACKGROUND) + } + colors.setBackground(canvas, ColorType.MAIN_BACKGROUND) + + languageLabel.setTextColor(colors.get(ColorType.KEY_TEXT)) + colors.setColor(clearButton, ColorType.KEY_ICON) + canvas.setStrokeColor(colors.get(ColorType.KEY_TEXT)) + + languageLabel.text = language + + // Setup bottom row keyboard + bottomRowKeyboard.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn) + bottomRowKeyboard.setKeyboardActionListener(this) + + try { + PointerTracker.switchTo(bottomRowKeyboard) + val kls = KeyboardLayoutSet.Builder.buildEmojiClipBottomRow(context, editorInfo) + val keyboard = kls.getKeyboard(KeyboardId.ELEMENT_HANDWRITING_BOTTOM_ROW) + bottomRowKeyboard.setKeyboard(keyboard) + + val languageOnSpacebarFormatType = LanguageOnSpacebarUtils.getLanguageOnSpacebarFormatType(keyboard.mId.mSubtype) + val hasMultipleEnabledIMEsOrSubtypes = RichInputMethodManager.getInstance().hasMultipleEnabledIMEsOrSubtypes(true) + bottomRowKeyboard.startDisplayLanguageOnSpacebar( + true, + languageOnSpacebarFormatType, + hasMultipleEnabledIMEsOrSubtypes + ) + } catch (e: Exception) { + Log.e("HandwritingView", "Failed to setup bottom row keyboard", e) + } + + clearCanvasAndComposition() + + val hasPlugin = HandwritingLoader.hasPlugin(context) + val overlay = findViewById(R.id.handwriting_plugin_overlay) + if (!hasPlugin) { + overlay?.visibility = View.VISIBLE + val titleText = findViewById(R.id.handwriting_plugin_title) + val summaryText = findViewById(R.id.handwriting_plugin_summary) + val iconView = findViewById(R.id.handwriting_plugin_icon) + val button = findViewById(R.id.handwriting_plugin_button) + + if (titleText != null) titleText.setTextColor(colors.get(ColorType.KEY_TEXT)) + if (summaryText != null) summaryText.setTextColor(colors.get(ColorType.KEY_HINT_TEXT)) + if (iconView != null) colors.setColor(iconView, ColorType.KEY_ICON) + + if (button != null) { + val btnBackground = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = 8f * context.resources.displayMetrics.density + setColor(colors.get(ColorType.ACTION_KEY_BACKGROUND)) + } + button.background = btnBackground + button.setTextColor(colors.get(ColorType.KEY_TEXT)) + + button.setOnClickListener { + val intent = android.content.Intent() + intent.setClass(context, helium314.keyboard.settings.SettingsActivity2::class.java) + intent.putExtra("screen", helium314.keyboard.settings.SettingsDestination.Libraries) + intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP + try { + context.startActivity(intent) + } catch (e: Exception) { + Log.e("HandwritingView", "Failed to start settings activity", e) + } + KeyboardSwitcher.getInstance().latinIME?.requestHideSelf(0) + } + } + } else { + overlay?.visibility = View.GONE + } + + val recognizer = HandwritingLoader.getRecognizer(context) + if (recognizer != null) { + recognizer.setLanguage(language) + recognitionExecutor.execute { + val isReady = recognizer.isLanguageReady(language) + mainHandler.post { + if (!isReady) { + languageLabel.text = "$language (Downloading...)" + recognizer.downloadModel(language, object : ModelDownloadListener { + override fun onProgress(progress: Float) { + mainHandler.post { + languageLabel.text = "$language (Downloading ${"%.0f".format(progress * 100)}%)" + } + } + override fun onComplete(success: Boolean) { + mainHandler.post { + if (success) { + languageLabel.text = language + android.widget.Toast.makeText(context, "Handwriting model downloaded", android.widget.Toast.LENGTH_SHORT).show() + } else { + languageLabel.text = "$language (Download failed)" + android.widget.Toast.makeText(context, "Failed to download handwriting model", android.widget.Toast.LENGTH_LONG).show() + } + } + } + }) + } else { + languageLabel.text = language + } + } + } + } + } + + fun stopHandwriting() { + commitCurrentComposition() + canvas.clear() + bottomRowKeyboard.closing() + } + + fun setHardwareAcceleratedDrawingEnabled(enabled: Boolean) { + if (enabled) { + setLayerType(LAYER_TYPE_HARDWARE, null) + } + } + + fun commitCurrentComposition() { + if (currentComposingText.isNotEmpty()) { + val latinIME = KeyboardSwitcher.getInstance().latinIME ?: return + val ic = latinIME.currentInputConnection ?: return + ic.finishComposingText() + currentComposingText = "" + latinIME.setSuggestions(SuggestedWords.getEmptyInstance()) + } + } + + fun clearCanvasAndComposition() { + canvas.clear() + currentComposingText = "" + val latinIME = KeyboardSwitcher.getInstance().latinIME + if (latinIME != null) { + val ic = latinIME.currentInputConnection + if (ic != null) { + ic.finishComposingText() + } + latinIME.setSuggestions(SuggestedWords.getEmptyInstance()) + } + } + + private val recognitionExecutor = java.util.concurrent.Executors.newSingleThreadExecutor() + private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper()) + + private fun performRecognition(strokes: List) { + if (strokes.isEmpty()) return + val recognizer = HandwritingLoader.getRecognizer(context) ?: return + + // setLanguage is fast (no blocking I/O), safe on main thread + recognizer.setLanguage(currentLanguage) + + // recognize() uses Tasks.await() which must not run on main thread + recognitionExecutor.execute { + try { + val results = recognizer.recognize(strokes) + if (results.isNullOrEmpty()) return@execute + + mainHandler.post { + val mainCandidate = results[0] + + val latinIME = KeyboardSwitcher.getInstance().latinIME ?: return@post + val ic = latinIME.currentInputConnection ?: return@post + + if (currentComposingText.isNotEmpty()) { + ic.finishComposingText() + val textBefore = ic.getTextBeforeCursor(1, 0) + if (textBefore != null && textBefore.isNotEmpty() && textBefore != " " && textBefore != "\n") { + ic.commitText(" ", 1) + } + } + + currentComposingText = mainCandidate + + // Update composing text + ic.setComposingText(mainCandidate, 1) + + // Populate suggestion strip with alternative candidates + val suggestionInfos = ArrayList() + for (word in results) { + suggestionInfos.add( + SuggestedWordInfo( + word, + "", + SuggestedWordInfo.MAX_SCORE, + SuggestedWordInfo.KIND_TYPED, + Dictionary.DICTIONARY_USER_TYPED, + SuggestedWordInfo.NOT_AN_INDEX, + SuggestedWordInfo.NOT_A_CONFIDENCE + ) + ) + } + + val typedWordInfo = SuggestedWordInfo( + mainCandidate, + "", + SuggestedWordInfo.MAX_SCORE, + SuggestedWordInfo.KIND_TYPED, + Dictionary.DICTIONARY_USER_TYPED, + SuggestedWordInfo.NOT_AN_INDEX, + SuggestedWordInfo.NOT_A_CONFIDENCE + ) + + val suggestedWords = SuggestedWords( + suggestionInfos, + null, + typedWordInfo, + false, + false, + false, + SuggestedWords.INPUT_STYLE_TYPING, + SuggestedWords.NOT_A_SEQUENCE_NUMBER + ) + latinIME.setSuggestions(suggestedWords) + } + } catch (e: Exception) { + Log.e("HandwritingView", "Error during recognition", e) + } + } + } + + // Intercept KeyboardActionListener events for the bottom row + override fun onCodeInput(primaryCode: Int, x: Int, y: Int, isKeyRepeat: Boolean) { + if (primaryCode == KeyCode.ALPHA) { + // Close handwriting mode + KeyboardSwitcher.getInstance().setAlphabetKeyboard() + return + } + if (primaryCode == KeyCode.CLEAR_HANDWRITING) { + clearCanvasAndComposition() + return + } + + // For other keys, commit the composition first when relevant + if (primaryCode == Constants.CODE_SPACE || primaryCode == Constants.CODE_ENTER) { + commitCurrentComposition() + } + + keyboardActionListener?.onCodeInput(primaryCode, x, y, isKeyRepeat) + } + + override fun onTextInput(text: String) { + commitCurrentComposition() + keyboardActionListener?.onTextInput(text) + } + + override fun onImageSelected(imageUri: String?) { + keyboardActionListener?.onImageSelected(imageUri) + } + + override fun onPressKey(primaryCode: Int, repeatCount: Int, isSinglePointer: Boolean, hapticEvent: HapticEvent?) { + keyboardActionListener?.onPressKey(primaryCode, repeatCount, isSinglePointer, hapticEvent) + } + + override fun onReleaseKey(primaryCode: Int, withSliding: Boolean) { + keyboardActionListener?.onReleaseKey(primaryCode, withSliding) + } + + override fun onLongPressKey(primaryCode: Int) { + keyboardActionListener?.onLongPressKey(primaryCode) + } + + override fun onKeyDown(keyCode: Int, keyEvent: android.view.KeyEvent?): Boolean { + return keyboardActionListener?.onKeyDown(keyCode, keyEvent) ?: false + } + + override fun onKeyUp(keyCode: Int, keyEvent: android.view.KeyEvent?): Boolean { + return keyboardActionListener?.onKeyUp(keyCode, keyEvent) ?: false + } + + override fun onStartBatchInput() { keyboardActionListener?.onStartBatchInput() } + override fun onUpdateBatchInput(p: helium314.keyboard.latin.common.InputPointers?) { keyboardActionListener?.onUpdateBatchInput(p) } + override fun onEndBatchInput(p: helium314.keyboard.latin.common.InputPointers?) { keyboardActionListener?.onEndBatchInput(p) } + override fun onCancelBatchInput() { keyboardActionListener?.onCancelBatchInput() } + override fun onCancelInput() { keyboardActionListener?.onCancelInput() } + override fun onFinishSlidingInput() { keyboardActionListener?.onFinishSlidingInput() } + override fun onCustomRequest(requestCode: Int): Boolean { return keyboardActionListener?.onCustomRequest(requestCode) ?: false } + override fun onHorizontalSpaceSwipe(steps: Int): Boolean { return keyboardActionListener?.onHorizontalSpaceSwipe(steps) ?: false } + override fun onVerticalSpaceSwipe(steps: Int): Boolean { return keyboardActionListener?.onVerticalSpaceSwipe(steps) ?: false } + override fun onEndSpaceSwipe() { keyboardActionListener?.onEndSpaceSwipe() } + override fun toggleNumpad(w: Boolean, f: Boolean): Boolean { return keyboardActionListener?.toggleNumpad(w, f) ?: false } + override fun onMoveDeletePointer(steps: Int) { keyboardActionListener?.onMoveDeletePointer(steps) } + override fun onUpWithDeletePointerActive() { keyboardActionListener?.onUpWithDeletePointerActive() } + override fun resetMetaState() { keyboardActionListener?.resetMetaState() } +} 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..79c939f1b 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -564,6 +564,15 @@ public boolean onUpdateSelection(final int oldSelStart, final int oldSelEnd, fin } } + if (oldSelStart != newSelStart || oldSelEnd != newSelEnd) { + if (newSelStart != mLastExpandedCursorPosition) { + mLastExpandedText = null; + mLastShortcutText = null; + mLastExpandedCursorPosition = -1; + mLastExpandedCursorOffset = -1; + } + } + final boolean selectionChangedOrSafeToReset = oldSelStart != newSelStart || oldSelEnd != newSelEnd // selection // changed || !mWordComposer.isComposingWord(); // safe to reset @@ -2211,6 +2220,32 @@ private void handleNonSeparatorEvent(final Event event, final SettingsValues set consumeJoinNextActionAndNotifyIfChanged(); // Combining mode: arm/refresh the grace timer for the next input. enterCombiningMode(settingsValues, true /* fromTap, unused — kept for clarity */); + if (helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.isEnabled(mLatinIME) + && helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.isImmediateEnabled(mLatinIME)) { + final String typedWord = mWordComposer.getTypedWord(); + final String prefix = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getPrefix(mLatinIME); + if (prefix.isEmpty()) { + final String expanded = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getExpandedWord(typedWord, mLatinIME); + if (expanded != null) { + commitExpandedText(typedWord, expanded); + resetComposingState(true); + } + } else { + final CharSequence textBefore = mConnection.getTextBeforeCursor(50, 0); + if (textBefore != null) { + final String textStr = textBefore.toString(); + final String targetSuffix = prefix + typedWord; + if (textStr.toLowerCase(java.util.Locale.US).endsWith(targetSuffix.toLowerCase(java.util.Locale.US))) { + final String expanded = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getExpandedWord(targetSuffix, mLatinIME); + if (expanded != null) { + mConnection.deleteTextBeforeCursor(prefix.length()); + commitExpandedText(targetSuffix, expanded); + resetComposingState(true); + } + } + } + } + } } else { final boolean swapWeakSpace = tryStripSpaceAndReturnWhetherShouldSwapInstead(event, inputTransaction); @@ -2493,6 +2528,28 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu } } + if (mLastExpandedText != null && !event.isKeyRepeat()) { + final int expectedCursor = mConnection.getExpectedSelectionEnd(); + if (expectedCursor == mLastExpandedCursorPosition) { + final int beforeLen = mLastExpandedCursorOffset; + final int afterLen = mLastExpandedText.length() - beforeLen; + final CharSequence textBefore = mConnection.getTextBeforeCursor(beforeLen, 0); + final CharSequence textAfter = mConnection.getTextAfterCursor(afterLen, 0); + final String expectedBefore = mLastExpandedText.substring(0, beforeLen); + final String expectedAfter = mLastExpandedText.substring(beforeLen); + if (textBefore != null && textBefore.toString().equals(expectedBefore) + && textAfter != null && textAfter.toString().equals(expectedAfter)) { + mConnection.setSelection(expectedCursor - beforeLen, expectedCursor + afterLen); + mConnection.commitText(mLastShortcutText, 1); + mLastExpandedText = null; + mLastShortcutText = null; + mLastExpandedCursorPosition = -1; + mLastExpandedCursorOffset = -1; + return; + } + } + } + // In many cases after backspace, we need to update the shift state. Normally we // need // to do this right away to avoid the shift state being out of date in case the @@ -2833,6 +2890,33 @@ private boolean trySwapSwapperAndSpace(final Event event, return true; } + private static boolean isSpaceStrippingPunctuation(final int codePoint) { + return codePoint == '.' + || codePoint == ',' + || codePoint == ';' + || codePoint == ':' + || codePoint == '!' + || codePoint == '?' + || codePoint == ')' + || codePoint == ']' + || codePoint == '}' + || codePoint == '؟' // Arabic question mark + || codePoint == '،' // Arabic comma + || codePoint == '؛' // Arabic semicolon + || codePoint == '।' // Hindi Danda + || codePoint == '॥' // Hindi Double Danda + || codePoint == '。' // CJK full stop + || codePoint == '、' // CJK enumeration comma + || codePoint == ',' // CJK fullwidth comma + || codePoint == '?' // CJK fullwidth question mark + || codePoint == '!' // CJK fullwidth exclamation mark + || codePoint == ':' // CJK fullwidth colon + || codePoint == ';' // CJK fullwidth semicolon + || codePoint == ')' // CJK fullwidth closing parenthesis + || codePoint == '】' // CJK fullwidth closing bracket + || codePoint == '』'; // CJK fullwidth closing quote + } + /* * Strip a trailing space if necessary and returns whether it's a swap weak * space situation. @@ -2852,6 +2936,14 @@ private boolean tryStripSpaceAndReturnWhetherShouldSwapInstead(final Event event mConnection.removeTrailingSpace(); return false; } + + if (isSpaceStrippingPunctuation(codePoint) + && !inputTransaction.getSettingsValues().isUsuallyPrecededBySpace(codePoint)) { + if (mConnection.getCodePointBeforeCursor() == Constants.CODE_SPACE) { + mConnection.removeTrailingSpace(); + } + } + if ((SpaceState.WEAK == inputTransaction.getSpaceState() || SpaceState.SWAP_PUNCTUATION == inputTransaction.getSpaceState()) && isFromSuggestionStrip) { @@ -4648,35 +4740,43 @@ private void handleCustomAIKey(int index) { final android.content.SharedPreferences prefs = helium314.keyboard.latin.utils.DeviceProtectedUtils .getSharedPreferences(mLatinIME); String prompt = prefs.getString("pref_custom_ai_prompt_" + index, ""); - String systemInstruction = ""; + StringBuilder systemInstructionBuilder = new StringBuilder(); boolean shouldAppend = false; // Keyword parsing for system instructions / personas if (prompt.contains("#editor")) { - systemInstruction = " You are a text editor tool. Output ONLY the edited text. Do not add any conversational filler."; + systemInstructionBuilder.append(" You are a text editor tool. Output ONLY the edited text. Do not add any conversational filler."); prompt = prompt.replace("#editor", "").trim(); - } else if (prompt.contains("#outputonly")) { - systemInstruction = " Output ONLY the result. Do not add introductions or explanations."; + } + if (prompt.contains("#outputonly")) { + systemInstructionBuilder.append(" Output ONLY the result. Do not add introductions or explanations."); prompt = prompt.replace("#outputonly", "").trim(); - } else if (prompt.contains("#proofread")) { - systemInstruction = " You are a proofreader. Fix grammar and spelling errors. Output ONLY the fixed text."; + } + if (prompt.contains("#proofread")) { + systemInstructionBuilder.append(" You are a proofreader. Fix grammar and spelling errors. Output ONLY the fixed text."); prompt = prompt.replace("#proofread", "").trim(); - } else if (prompt.contains("#paraphrase")) { - systemInstruction = " You are a paraphrasing tool. Rewrite the text using different words while keeping the meaning. Output ONLY the result."; + } + if (prompt.contains("#paraphrase")) { + systemInstructionBuilder.append(" You are a paraphrasing tool. Rewrite the text using different words while keeping the meaning. Output ONLY the result."); prompt = prompt.replace("#paraphrase", "").trim(); - } else if (prompt.contains("#summarize")) { - systemInstruction = " You are a summarizer. Provide a concise summary of the text. Output ONLY the summary."; + } + if (prompt.contains("#summarize")) { + systemInstructionBuilder.append(" You are a summarizer. Provide a concise summary of the text. Output ONLY the summary."); prompt = prompt.replace("#summarize", "").trim(); - } else if (prompt.contains("#expand")) { - systemInstruction = " You are a creative writing assistant. Expand on the text with more details. Output ONLY the result."; + } + if (prompt.contains("#expand")) { + systemInstructionBuilder.append(" You are a creative writing assistant. Expand on the text with more details. Output ONLY the result."); prompt = prompt.replace("#expand", "").trim(); - } else if (prompt.contains("#toneshift")) { - systemInstruction = " You are a tone modifier. Adjust the tone as requested. Output ONLY the result."; + } + if (prompt.contains("#toneshift")) { + systemInstructionBuilder.append(" You are a tone modifier. Adjust the tone as requested. Output ONLY the result."); prompt = prompt.replace("#toneshift", "").trim(); - } else if (prompt.contains("#generate")) { - systemInstruction = " You are a creative content generator. Output ONLY the generated content."; + } + if (prompt.contains("#generate")) { + systemInstructionBuilder.append(" You are a creative content generator. Output ONLY the generated content."); prompt = prompt.replace("#generate", "").trim(); } + String systemInstruction = systemInstructionBuilder.toString(); // Input handling keywords if (prompt.contains("#append")) { 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..c0c14c172 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -40,6 +40,7 @@ object Defaults { LayoutType.CLIPBOARD_BOTTOM -> "clip_bottom_row" LayoutType.SHORTCUT_TOP -> "shortcut_top" LayoutType.SHORTCUT_BOTTOM -> "shortcut_bottom" + LayoutType.HANDWRITING_BOTTOM -> "handwriting_bottom_row" } const val PREF_SPLIT_TOOLBAR = false @@ -118,6 +119,7 @@ object Defaults { const val PREF_SUGGEST_CLIPBOARD_CONTENT = true const val PREF_SUGGEST_SCREENSHOTS = false const val PREF_COMPRESS_SCREENSHOTS = true + const val PREF_AUTO_READ_OTP = false const val PREF_GESTURE_INPUT = true const val PREF_VIBRATION_DURATION_SETTINGS = -1 const val PREF_VIBRATION_AMPLITUDE_SETTINGS = -1 @@ -186,10 +188,15 @@ object Defaults { const val PREF_FORCE_AUTO_CAPS = false const val PREF_OFFLINE_TEMP = 0.1f // Lower for faster, more deterministic proofreading const val PREF_OFFLINE_TOP_P = 0.5f // Lower for faster token sampling + const val PREF_OFFLINE_TOP_K = 40 + const val PREF_OFFLINE_MIN_P = 0.05f + const val PREF_OFFLINE_SHOW_THINKING = false const val PREF_OFFLINE_SYSTEM_PROMPT = "Correct the grammar and spelling. Output only the corrected text." + const val PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT = "Translate the following text to {lang}. Output only the translation, nothing else:\n\n" const val PREF_OFFLINE_MAX_TOKENS = 64 // Accurate (64 tokens) default const val PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE = "French" const val PREF_OFFLINE_KEEP_MODEL_LOADED = false + const val PREF_AI_ALLOW_INSECURE_CONNECTIONS = false const val PREF_ENABLE_CLIPBOARD_HISTORY = true const val PREF_CLIPBOARD_HISTORY_RETENTION_TIME = 15 // minutes const val PREF_CLIPBOARD_HISTORY_PINNED_FIRST = true 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..64f1c3b70 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -220,14 +220,20 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_FORCE_AUTO_CAPS = "force_auto_caps"; public static final String PREF_OFFLINE_TEMP = "offline_temp"; public static final String PREF_OFFLINE_TOP_P = "offline_top_p"; + public static final String PREF_OFFLINE_TOP_K = "offline_top_k"; + public static final String PREF_OFFLINE_MIN_P = "offline_min_p"; + public static final String PREF_OFFLINE_SHOW_THINKING = "offline_show_thinking"; public static final String PREF_OFFLINE_SYSTEM_PROMPT = "offline_system_prompt"; + public static final String PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT = "offline_translate_system_prompt"; public static final String PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE = "offline_translate_target_language"; public static final String PREF_OFFLINE_MAX_TOKENS = "offline_max_tokens"; public static final String PREF_OFFLINE_KEEP_MODEL_LOADED = "offline_keep_model_loaded"; + public static final String PREF_AI_ALLOW_INSECURE_CONNECTIONS = "ai_allow_insecure_connections"; public static final String PREF_ENABLE_CLIPBOARD_HISTORY = "enable_clipboard_history"; public static final String PREF_SUGGEST_SCREENSHOTS = "suggest_screenshots"; public static final String PREF_COMPRESS_SCREENSHOTS = "compress_screenshots"; + public static final String PREF_AUTO_READ_OTP = "auto_read_otp"; public static final String PREF_CLIPBOARD_HISTORY_RETENTION_TIME = "clipboard_history_retention_time"; public static final String PREF_CLIPBOARD_HISTORY_PINNED_FIRST = "clipboard_history_pinned_first"; public static final String PREF_CLIPBOARD_FOLD_PINNED = "clipboard_fold_pinned"; 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..5101f5a71 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -208,6 +208,7 @@ public class SettingsValues { public final boolean mSuggestClipboardContent; public final boolean mSuggestScreenshots; public final boolean mCompressScreenshots; + public final boolean mAutoReadOtp; public final SettingsValuesForSuggestion mSettingsValuesForSuggestion; public final boolean mIncognitoModeEnabled; public final boolean mLongPressSymbolsForNumpad; @@ -311,6 +312,8 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina Defaults.PREF_SUGGEST_CLIPBOARD_CONTENT); mSuggestScreenshots = prefs.getBoolean(Settings.PREF_SUGGEST_SCREENSHOTS, Defaults.PREF_SUGGEST_SCREENSHOTS); + mAutoReadOtp = prefs.getBoolean(Settings.PREF_AUTO_READ_OTP, + Defaults.PREF_AUTO_READ_OTP); mCompressScreenshots = prefs.getBoolean(Settings.PREF_COMPRESS_SCREENSHOTS, Defaults.PREF_COMPRESS_SCREENSHOTS); mDoubleSpacePeriodTimeout = 1100; // ms diff --git a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt index 95e9ea8fd..db2701b7b 100644 --- a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt +++ b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt @@ -54,6 +54,8 @@ import helium314.keyboard.latin.utils.ToolbarKey import helium314.keyboard.latin.utils.ToolbarMode import helium314.keyboard.latin.utils.addPinnedKey import helium314.keyboard.latin.utils.createToolbarKey +import helium314.keyboard.latin.utils.isRepeatableToolbarKey +import helium314.keyboard.latin.utils.RepeatableKeyTouchListener import helium314.keyboard.latin.utils.dpToPx import helium314.keyboard.latin.utils.getCodeForToolbarKey import helium314.keyboard.latin.utils.getCodeForToolbarKeyLongClick @@ -920,8 +922,21 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) } private fun setupKey(view: ImageButton, colors: Colors) { - view.setOnClickListener(this) - view.setOnLongClickListener(this) + val tag = view.tag + if (tag is ToolbarKey && isRepeatableToolbarKey(tag)) { + view.setOnTouchListener(RepeatableKeyTouchListener { repeatCount -> + if (repeatCount == 0 || repeatCount % 4 == 0) { + AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(KeyCode.NOT_SPECIFIED, view, HapticEvent.KEY_PRESS) + } + val code = getCodeForToolbarKey(tag) + if (code != KeyCode.UNSPECIFIED) { + listener.onCodeInput(code, Constants.SUGGESTION_STRIP_COORDINATE, Constants.SUGGESTION_STRIP_COORDINATE, repeatCount > 0) + } + }) + } else { + view.setOnClickListener(this) + view.setOnLongClickListener(this) + } colors.setColor(view, ColorType.TOOL_BAR_KEY) // Set circular background for toolbar keys view.setBackgroundResource(R.drawable.toolbar_key_background) diff --git a/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt b/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt index c64459746..2e0856b83 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt @@ -9,7 +9,7 @@ import java.util.EnumMap enum class LayoutType { MAIN, SYMBOLS, MORE_SYMBOLS, FUNCTIONAL, NUMBER, NUMBER_ROW, NUMPAD, NUMPAD_LANDSCAPE, PHONE, PHONE_SYMBOLS, EMOJI_BOTTOM, CLIPBOARD_BOTTOM, - SHORTCUT_TOP, SHORTCUT_BOTTOM; + SHORTCUT_TOP, SHORTCUT_BOTTOM, HANDWRITING_BOTTOM; companion object { fun EnumMap.toExtraValue() = map { it.key.name + Separators.KV + it.value }.joinToString(Separators.ENTRY) @@ -40,6 +40,7 @@ enum class LayoutType { CLIPBOARD_BOTTOM -> R.string.layout_clip_bottom_row SHORTCUT_TOP -> R.string.layout_shortcut_top SHORTCUT_BOTTOM -> R.string.layout_shortcut_bottom + HANDWRITING_BOTTOM -> R.string.layout_emoji_bottom_row } fun getMainLayoutFromExtraValue(extraValue: String): String? { diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt index 5c91ebe86..eff85bce3 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt @@ -15,12 +15,18 @@ import java.util.Locale object TextExpanderUtils { const val PREF_ENABLED = "pref_text_expander_enabled" const val PREF_PREFIX = "pref_text_expander_prefix" + const val PREF_IMMEDIATE = "pref_text_expander_immediate" const val PREF_DATA = "pref_text_expander_data" + const val REGEX_PREFIX = "__regex__:" fun isEnabled(context: Context): Boolean { return context.prefs().getBoolean(PREF_ENABLED, false) } + fun isImmediateEnabled(context: Context): Boolean { + return context.prefs().getBoolean(PREF_IMMEDIATE, false) + } + fun getPrefix(context: Context): String { return context.prefs().getString(PREF_PREFIX, "") ?: "" } @@ -195,8 +201,27 @@ object TextExpanderUtils { val shortcuts = getShortcuts(context) // Check exact match or lowercase match - val template = shortcuts[shortcut] ?: shortcuts[shortcut.lowercase(Locale.getDefault())] ?: return null + val template = shortcuts[shortcut] ?: shortcuts[shortcut.lowercase(Locale.getDefault())] + if (template != null) { + return expand(template, context) + } + + // Check regex matches + for ((key, value) in shortcuts) { + if (key.startsWith(REGEX_PREFIX)) { + val patternStr = key.substring(REGEX_PREFIX.length) + try { + val regex = Regex(patternStr, RegexOption.IGNORE_CASE) + if (regex.matches(shortcut)) { + val replaced = regex.replace(shortcut, value) + return expand(replaced, context) + } + } catch (e: Exception) { + // ignore invalid regex + } + } + } - return expand(template, context) + return null } } diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt index de7de8c46..2d38093cb 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt @@ -12,6 +12,11 @@ import android.widget.ImageButton import android.widget.ImageView import androidx.core.content.edit import androidx.core.graphics.ColorUtils +import android.view.View +import android.view.MotionEvent +import android.os.Handler +import android.os.Looper +import android.annotation.SuppressLint import androidx.core.view.forEach import helium314.keyboard.keyboard.internal.KeyboardIconsSet import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode @@ -263,6 +268,7 @@ fun getCodeForToolbarKey(key: ToolbarKey) = Settings.getInstance().getCustomTool CLIPBOARD -> KeyCode.CLIPBOARD CLIPBOARD_SEARCH -> KeyCode.CLIPBOARD_SEARCH NUMPAD -> KeyCode.NUMPAD + HANDWRITING -> KeyCode.HANDWRITING UNDO -> KeyCode.UNDO REDO -> KeyCode.REDO SETTINGS -> KeyCode.SETTINGS @@ -334,7 +340,7 @@ fun getCodeForToolbarKeyLongClick(key: ToolbarKey) = Settings.getInstance().getC // names need to be aligned with resources strings (using lowercase of key.name) enum class ToolbarKey { - VOICE, CLIPBOARD, CLIPBOARD_SEARCH, NUMPAD, UNDO, REDO, SETTINGS, SELECT_ALL, SELECT_WORD, COPY, CUT, PASTE, ONE_HANDED, SPLIT, FLOATING, + VOICE, CLIPBOARD, CLIPBOARD_SEARCH, NUMPAD, HANDWRITING, UNDO, REDO, SETTINGS, SELECT_ALL, SELECT_WORD, COPY, CUT, PASTE, ONE_HANDED, SPLIT, FLOATING, INCOGNITO, TOUCHPAD, AUTOCORRECT, AUTOSPACE, AUTO_CAP, FORCE_AUTO_CAP, CLEAR_CLIPBOARD, CLOSE_HISTORY, EMOJI, LEFT, RIGHT, UP, DOWN, WORD_LEFT, WORD_RIGHT, PAGE_UP, PAGE_DOWN, FULL_LEFT, FULL_RIGHT, PAGE_START, PAGE_END, JOIN_NEXT, FORCE_NEXT_SPACE, UNDO_WORD, PROOFREAD, TRANSLATE, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, @@ -348,11 +354,13 @@ enum class ToolbarMode { val toolbarKeyStrings = entries.associateWithTo(EnumMap(ToolbarKey::class.java)) { it.toString().lowercase(Locale.US) } private val excludedKeys by lazy { - val customAiKeys = if (BuildConfig.FLAVOR != "standard" && BuildConfig.FLAVOR != "standardOptimised") + val customAiKeys = if (BuildConfig.FLAVOR != "standard" && BuildConfig.FLAVOR != "standardOptimised" && BuildConfig.FLAVOR != "offline") ToolbarKey.entries.filter { it.name.startsWith("CUSTOM_AI_") } else emptyList() val otherKeys = if (BuildConfig.FLAVOR == "offlinelite") - listOf(CLOSE_HISTORY, PROOFREAD, TRANSLATE, CLIPBOARD_SEARCH) + listOf(CLOSE_HISTORY, PROOFREAD, TRANSLATE, CLIPBOARD_SEARCH, HANDWRITING) + else if (BuildConfig.FLAVOR == "offline") + listOf(CLOSE_HISTORY, CLIPBOARD_SEARCH, HANDWRITING) else listOf(CLOSE_HISTORY, CLIPBOARD_SEARCH) customAiKeys + otherKeys @@ -360,9 +368,9 @@ private val excludedKeys by lazy { val defaultToolbarPref by lazy { val default = when (helium314.keyboard.latin.BuildConfig.FLAVOR) { - "offline" -> listOf(SETTINGS, VOICE, CLIPBOARD, UNDO, INCOGNITO, COPY, PASTE, PROOFREAD, TRANSLATE) + "offline" -> listOf(SETTINGS, VOICE, CLIPBOARD, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, UNDO, INCOGNITO, COPY, PASTE, PROOFREAD, TRANSLATE) "offlinelite" -> listOf(SETTINGS, VOICE, CLIPBOARD, UNDO, INCOGNITO, COPY, PASTE) - else -> listOf(SETTINGS, VOICE, CLIPBOARD, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, UNDO, PROOFREAD, TRANSLATE, INCOGNITO, TOUCHPAD, FLOATING, NUMPAD, COPY, PASTE, SELECT_ALL) + else -> listOf(SETTINGS, VOICE, CLIPBOARD, HANDWRITING, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, UNDO, PROOFREAD, TRANSLATE, INCOGNITO, TOUCHPAD, FLOATING, NUMPAD, COPY, PASTE, SELECT_ALL) } val others = entries.filterNot { it in default || it in excludedKeys } @@ -397,12 +405,13 @@ fun upgradeToolbarPrefs(prefs: SharedPreferences) { private fun upgradeToolbarPref(prefs: SharedPreferences, pref: String, default: String) { if (!prefs.contains(pref)) return - val list = prefs.getString(pref, default)!!.split(Separators.ENTRY).toMutableList() + val originalString = prefs.getString(pref, default)!! + val list = originalString.split(Separators.ENTRY).toMutableList() val splitDefault = default.split(Separators.ENTRY) splitDefault.forEach { entry -> val keyWithSeparator = entry.substringBefore(Separators.KV) + Separators.KV if (list.none { it.startsWith(keyWithSeparator) }) - list.add("${keyWithSeparator}false") + list.add(entry) } // likely not needed, but better prepare for possibility of key removal list.removeAll { @@ -413,7 +422,10 @@ private fun upgradeToolbarPref(prefs: SharedPreferences, pref: String, default: true } } - prefs.edit { putString(pref, list.joinToString(Separators.ENTRY)) } + val newString = list.joinToString(Separators.ENTRY) + if (newString != originalString) { + prefs.edit { putString(pref, newString) } + } } fun getEnabledToolbarKeys(prefs: SharedPreferences) = getEnabledToolbarKeys(prefs, Settings.PREF_TOOLBAR_KEYS, defaultToolbarPref) @@ -449,7 +461,8 @@ private fun getEnabledToolbarKeys(prefs: SharedPreferences, pref: String, defaul val split = it.split(Separators.KV) if (split.last() == "true") { try { - ToolbarKey.valueOf(split.first()) + val key = ToolbarKey.valueOf(split.first()) + if (key in excludedKeys) null else key } catch (_: IllegalArgumentException) { null } @@ -491,3 +504,54 @@ fun clearCustomToolbarKeyCodes() { } private var customToolbarKeyCodes: EnumMap>? = null + +fun isRepeatableToolbarKey(key: ToolbarKey): Boolean { + return when (key) { + LEFT, RIGHT, UP, DOWN, + WORD_LEFT, WORD_RIGHT, + PAGE_UP, PAGE_DOWN -> true + else -> false + } +} + +class RepeatableKeyTouchListener( + private val onClick: (repeatCount: Int) -> Unit +) : View.OnTouchListener { + private val handler = Handler(Looper.getMainLooper()) + private var repeatCount = 0 + private val runnable = object : Runnable { + override fun run() { + repeatCount++ + onClick(repeatCount) + handler.postDelayed(this, 50L) + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View, event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + repeatCount = 0 + onClick(0) + handler.postDelayed(runnable, 400L) + v.isPressed = true + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + handler.removeCallbacks(runnable) + v.isPressed = false + return true + } + MotionEvent.ACTION_MOVE -> { + val x = event.x + val y = event.y + if (x < 0 || x > v.width || y < 0 || y > v.height) { + handler.removeCallbacks(runnable) + v.isPressed = false + } + return true + } + } + return false + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt index a9189ec0c..a9dd8b6dc 100644 --- a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt @@ -244,7 +244,7 @@ fun SearchScreen( content() } } else { - val items = remember(searchText.text) { filteredItems(searchText.text) } + val items = remember(searchText.text, filteredItems) { filteredItems(searchText.text) } Scaffold( contentWindowInsets = WindowInsets(0) ) { innerPadding -> diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt index 5dc52e6a5..7360609e4 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt @@ -115,4 +115,5 @@ object SettingsWithoutKey { const val GROQ_MODEL = "groq_model" const val CUSTOM_AI_KEYS = "custom_ai_keys" const val OFFLINE_KEEP_MODEL_LOADED = "offline_keep_model_loaded" + const val AI_ALLOW_INSECURE_CONNECTIONS = "ai_allow_insecure_connections" } diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt index 2697a9d2f..ffd245550 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt @@ -31,6 +31,7 @@ import helium314.keyboard.settings.screens.LanguageScreen import helium314.keyboard.settings.screens.MainSettingsScreen import helium314.keyboard.settings.screens.PersonalDictionariesScreen import helium314.keyboard.settings.screens.PersonalDictionaryScreen +import helium314.keyboard.settings.screens.BlockedWordsScreen import helium314.keyboard.settings.screens.PreferencesScreen import helium314.keyboard.settings.screens.SecondaryLayoutScreen import helium314.keyboard.settings.screens.SubtypeScreen @@ -150,6 +151,9 @@ fun SettingsNavHost( composable(SettingsDestination.PersonalDictionaries) { PersonalDictionariesScreen(onClickBack = ::goBack) } + composable(SettingsDestination.BlockedWords) { + BlockedWordsScreen(onClickBack = ::goBack) + } composable(SettingsDestination.Languages) { LanguageScreen(onClickBack = ::goBack) } @@ -196,6 +200,7 @@ object SettingsDestination { const val ColorsNight = "colors_night/" const val PersonalDictionaries = "personal_dictionaries" const val PersonalDictionary = "personal_dictionary/" + const val BlockedWords = "blocked_words" const val Languages = "languages" const val Subtype = "subtype/" const val Layouts = "layouts" diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt index 00c51dae6..94a70790f 100644 --- a/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt +++ b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt @@ -12,10 +12,21 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.material3.Checkbox import helium314.keyboard.dictionarypack.DictionaryPackConstants import helium314.keyboard.keyboard.KeyboardSwitcher import helium314.keyboard.keyboard.emoji.SupportedEmojis @@ -56,17 +67,74 @@ fun BackupRestorePreference(setting: Setting) { var showDialog by rememberSaveable { mutableStateOf(false) } val ctx = LocalContext.current var error: String? by rememberSaveable { mutableStateOf(null) } - val backupLauncher = backupLauncher { error = it } - val restoreLauncher = restoreLauncher { error = it } + var selectedCategories by remember { + mutableStateOf( + setOf( + BackupCategory.LAYOUTS, + BackupCategory.THEME_APPEARANCE, + BackupCategory.DICTIONARY_HISTORY, + BackupCategory.CLIPBOARD, + BackupCategory.GENERAL_SETTINGS + ) + ) + } + val backupLauncher = backupLauncher(selectedCategories) { error = it } + val restoreLauncher = restoreLauncher(selectedCategories) { error = it } Preference(name = setting.title, onClick = { showDialog = true }) if (showDialog) { ConfirmationDialog( onDismissRequest = { showDialog = false }, title = { Text(stringResource(R.string.backup_restore_title)) }, - content = { Text(stringResource(R.string.backup_restore_message)) }, + content = { + Column { + Text( + text = stringResource(R.string.backup_select_items), + modifier = Modifier.padding(bottom = 8.dp) + ) + val categories = listOf( + BackupCategory.LAYOUTS to R.string.backup_category_layouts, + BackupCategory.THEME_APPEARANCE to R.string.backup_category_theme, + BackupCategory.DICTIONARY_HISTORY to R.string.backup_category_dictionary, + BackupCategory.CLIPBOARD to R.string.backup_category_clipboard, + BackupCategory.GENERAL_SETTINGS to R.string.backup_category_general + ) + categories.forEach { (category, stringResId) -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .toggleable( + value = selectedCategories.contains(category), + onValueChange = { checked -> + selectedCategories = if (checked) { + selectedCategories + category + } else { + selectedCategories - category + } + } + ) + .padding(vertical = 4.dp) + ) { + Checkbox( + checked = selectedCategories.contains(category), + onCheckedChange = null + ) + Text( + text = stringResource(stringResId), + modifier = Modifier.padding(start = 8.dp) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text(text = stringResource(R.string.backup_restore_message)) + } + }, confirmButtonText = stringResource(R.string.button_backup), neutralButtonText = stringResource(R.string.button_restore), onNeutral = { + if (selectedCategories.isEmpty()) { + Toast.makeText(ctx, "Please select at least one category", Toast.LENGTH_SHORT).show() + return@ConfirmationDialog + } showDialog = false val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) @@ -74,6 +142,10 @@ fun BackupRestorePreference(setting: Setting) { restoreLauncher.launch(intent) }, onConfirmed = { + if (selectedCategories.isEmpty()) { + Toast.makeText(ctx, "Please select at least one category", Toast.LENGTH_SHORT).show() + return@ConfirmationDialog + } val currentDate = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Calendar.getInstance().time) val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) @@ -97,32 +169,40 @@ fun BackupRestorePreference(setting: Setting) { } @Composable -private fun backupLauncher(onError: (String) -> Unit): ManagedActivityResultLauncher { +private fun backupLauncher( + selectedCategories: Set, + onError: (String) -> Unit +): ManagedActivityResultLauncher { val ctx = LocalContext.current return filePicker { uri -> - // zip all files matching the backup patterns - // essentially this is the typed words information, and user-added dictionaries val filesDir = ctx.filesDir ?: return@filePicker val filesPath = filesDir.path + File.separator val files = mutableListOf() filesDir.walk().forEach { file -> val path = file.path.replace(filesPath, "") - if (file.isFile && backupFilePatterns.any { path.matches(it) }) - files.add(file) + if (file.isFile && backupFilePatterns.any { path.matches(it) }) { + val cat = getCategoryForFilePath(path) + if (cat == null || selectedCategories.contains(cat)) { + files.add(file) + } + } } val protectedFilesDir = DeviceProtectedUtils.getFilesDir(ctx) val protectedFilesPath = protectedFilesDir.path + File.separator val protectedFiles = mutableListOf() protectedFilesDir.walk().forEach { file -> val path = file.path.replace(protectedFilesPath, "") - if (file.isFile && backupFilePatterns.any { path.matches(it) }) - protectedFiles.add(file) + if (file.isFile && backupFilePatterns.any { path.matches(it) }) { + val cat = getCategoryForFilePath(path) + if (cat == null || selectedCategories.contains(cat)) { + protectedFiles.add(file) + } + } } val wait = CountDownLatch(1) ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute { try { ctx.getActivity()?.contentResolver?.openOutputStream(uri)?.use { os -> - // write files to zip val zipStream = ZipOutputStream(os) files.forEach { val fileStream = FileInputStream(it).buffered() @@ -138,27 +218,40 @@ private fun backupLauncher(onError: (String) -> Unit): ManagedActivityResultLaun fileStream.close() zipStream.closeEntry() } - val dbFile = ctx.getDatabasePath(Database.NAME) - if (dbFile.exists()) { - val fileStream = FileInputStream(dbFile).buffered() - zipStream.putNextEntry(ZipEntry(Database.NAME)) - fileStream.copyTo(zipStream, 1024) - fileStream.close() - zipStream.closeEntry() + if (selectedCategories.contains(BackupCategory.CLIPBOARD)) { + val dbFile = ctx.getDatabasePath(Database.NAME) + if (dbFile.exists()) { + val fileStream = FileInputStream(dbFile).buffered() + zipStream.putNextEntry(ZipEntry(Database.NAME)) + fileStream.copyTo(zipStream, 1024) + fileStream.close() + zipStream.closeEntry() + } + } + val filteredPrefs = ctx.prefs().all.filter { + selectedCategories.contains(getCategoryForPrefKey(it.key)) } zipStream.putNextEntry(ZipEntry(PREFS_FILE_NAME)) - settingsToJsonStream(ctx.prefs().all, zipStream) + settingsToJsonStream(filteredPrefs, zipStream) zipStream.closeEntry() + + val filteredProtectedPrefs = ctx.protectedPrefs().all.filter { + selectedCategories.contains(getCategoryForPrefKey(it.key)) + } zipStream.putNextEntry(ZipEntry(PROTECTED_PREFS_FILE_NAME)) - settingsToJsonStream(ctx.protectedPrefs().all, zipStream) + settingsToJsonStream(filteredProtectedPrefs, zipStream) zipStream.closeEntry() - // back up auxiliary SharedPreferences files used by individual features - // (gemini_prefs is intentionally excluded: it is EncryptedSharedPreferences - // whose values are tied to a device-specific master key and contains API keys) + for ((entryName, prefsForBackup) in auxiliaryPrefsToBackUp(ctx)) { - zipStream.putNextEntry(ZipEntry(entryName)) - settingsToJsonStream(prefsForBackup.all, zipStream) - zipStream.closeEntry() + val cat = getCategoryForFilePath(entryName) + if (cat == null || selectedCategories.contains(cat)) { + val filteredAuxPrefs = prefsForBackup.all.filter { + selectedCategories.contains(getCategoryForPrefKey(it.key)) + } + zipStream.putNextEntry(ZipEntry(entryName)) + settingsToJsonStream(filteredAuxPrefs, zipStream) + zipStream.closeEntry() + } } zipStream.close() } @@ -174,7 +267,10 @@ private fun backupLauncher(onError: (String) -> Unit): ManagedActivityResultLaun } @Composable -private fun restoreLauncher(onError: (String) -> Unit): ManagedActivityResultLauncher { +private fun restoreLauncher( + selectedCategories: Set, + onError: (String) -> Unit +): ManagedActivityResultLauncher { val ctx = LocalContext.current return filePicker { uri -> val wait = CountDownLatch(1) @@ -186,40 +282,92 @@ private fun restoreLauncher(onError: (String) -> Unit): ManagedActivityResultLau var entry: ZipEntry? = zip.nextEntry val filesDir = ctx.filesDir ?: return@execute val deviceProtectedFilesDir = DeviceProtectedUtils.getFilesDir(ctx) - filesDir.deleteRecursively() - deviceProtectedFilesDir.deleteRecursively() + + // Targeted deletion based on selected categories + if (selectedCategories.contains(BackupCategory.LAYOUTS)) { + File(filesDir, "layouts").deleteRecursively() + } + if (selectedCategories.contains(BackupCategory.DICTIONARY_HISTORY)) { + File(filesDir, "dicts").deleteRecursively() + File(filesDir, "blacklists").deleteRecursively() + filesDir.listFiles()?.forEach { + if (it.name.startsWith("UserHistoryDictionary")) it.delete() + } + } + if (selectedCategories.contains(BackupCategory.THEME_APPEARANCE)) { + File(filesDir, "custom_font").delete() + File(filesDir, "custom_emoji_font").delete() + deviceProtectedFilesDir.listFiles()?.forEach { + if (it.name.startsWith("custom_background_image")) it.delete() + } + } + if (selectedCategories.contains(BackupCategory.CLIPBOARD)) { + ctx.deleteDatabase(Database.NAME) + } + LayoutUtilsCustom.onLayoutFileChanged() Settings.getInstance().stopListener() while (entry != null) { if (entry.name.startsWith("unprotected${File.separator}")) { val adjustedName = entry.name.substringAfter("unprotected${File.separator}") if (backupFilePatterns.any { adjustedName.matches(it) }) { - if (!restoreEntryToDir(zip, deviceProtectedFilesDir, adjustedName)) { - Log.w("AdvancedScreen", "skipping unsafe backup entry $adjustedName") + val cat = getCategoryForFilePath(adjustedName) + if (cat == null || selectedCategories.contains(cat)) { + File(deviceProtectedFilesDir, adjustedName).delete() + if (!restoreEntryToDir(zip, deviceProtectedFilesDir, adjustedName)) { + Log.w("AdvancedScreen", "skipping unsafe backup entry $adjustedName") + } } } } else if (backupFilePatterns.any { entry.name.matches(it) }) { - if (!restoreEntryToDir(zip, filesDir, entry.name)) { - Log.w("AdvancedScreen", "skipping unsafe backup entry ${entry.name}") + val cat = getCategoryForFilePath(entry.name) + if (cat == null || selectedCategories.contains(cat)) { + File(filesDir, entry.name).delete() + if (!restoreEntryToDir(zip, filesDir, entry.name)) { + Log.w("AdvancedScreen", "skipping unsafe backup entry ${entry.name}") + } } } else if (entry.name == Database.NAME) { - FileUtils.copyStreamToNewFile(zip, restoredDb) + if (selectedCategories.contains(BackupCategory.CLIPBOARD)) { + FileUtils.copyStreamToNewFile(zip, restoredDb) + } } else if (entry.name == PREFS_FILE_NAME) { val prefLines = String(zip.readBytes()).split("\n") val prefs = ctx.prefs() - prefs.edit(commit = true) { clear() } - readJsonLinesToSettings(prefLines, prefs) + prefs.edit(commit = true) { + prefs.all.keys.forEach { key -> + if (selectedCategories.contains(getCategoryForPrefKey(key))) { + remove(key) + } + } + } + readJsonLinesToSettings(prefLines, prefs, selectedCategories) } else if (entry.name == PROTECTED_PREFS_FILE_NAME) { val prefLines = String(zip.readBytes()).split("\n") val protectedPrefs = ctx.protectedPrefs() - protectedPrefs.edit(commit = true) { clear() } - readJsonLinesToSettings(prefLines, protectedPrefs) + protectedPrefs.edit(commit = true) { + protectedPrefs.all.keys.forEach { key -> + if (selectedCategories.contains(getCategoryForPrefKey(key))) { + remove(key) + } + } + } + readJsonLinesToSettings(prefLines, protectedPrefs, selectedCategories) } else { val auxPrefs = auxiliaryPrefsToBackUp(ctx)[entry.name] if (auxPrefs != null) { - val prefLines = String(zip.readBytes()).split("\n") - auxPrefs.edit(commit = true) { clear() } - readJsonLinesToSettings(prefLines, auxPrefs) + val cat = getCategoryForFilePath(entry.name) + if (cat == null || selectedCategories.contains(cat)) { + val prefLines = String(zip.readBytes()).split("\n") + auxPrefs.edit(commit = true) { + auxPrefs.all.keys.forEach { key -> + if (selectedCategories.contains(getCategoryForPrefKey(key))) { + remove(key) + } + } + } + readJsonLinesToSettings(prefLines, auxPrefs, selectedCategories) + } } } zip.closeEntry() @@ -227,8 +375,9 @@ private fun restoreLauncher(onError: (String) -> Unit): ManagedActivityResultLau } } } - - Database.copyFromDb(restoredDb, ctx) + if (selectedCategories.contains(BackupCategory.CLIPBOARD)) { + Database.copyFromDb(restoredDb, ctx) + } Handler(Looper.getMainLooper()).post { FeedbackManager.message(ctx, R.string.backup_restored) } @@ -277,22 +426,32 @@ private fun settingsToJsonStream(settings: Map, out: OutputStream out.write(Json.encodeToString(stringSets).toByteArray()) } -private fun readJsonLinesToSettings(list: List, prefs: SharedPreferences): Boolean { +private fun readJsonLinesToSettings(list: List, prefs: SharedPreferences, selectedCategories: Set): Boolean { val i = list.iterator() val e = prefs.edit() try { while (i.hasNext()) { when (i.next()) { - "boolean settings" -> Json.decodeFromString>(i.next()).forEach { e.putBoolean(it.key, it.value) } - "int settings" -> Json.decodeFromString>(i.next()).forEach { e.putInt(it.key, it.value) } - "long settings" -> Json.decodeFromString>(i.next()).forEach { e.putLong(it.key, it.value) } - "float settings" -> Json.decodeFromString>(i.next()).forEach { e.putFloat(it.key, it.value) } - "string settings" -> Json.decodeFromString>(i.next()).forEach { e.putString(it.key, it.value) } - "string set settings" -> Json.decodeFromString>>(i.next()).forEach { e.putStringSet(it.key, it.value) } + "boolean settings" -> Json.decodeFromString>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putBoolean(it.key, it.value) } + "int settings" -> Json.decodeFromString>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putInt(it.key, it.value) } + "long settings" -> Json.decodeFromString>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putLong(it.key, it.value) } + "float settings" -> Json.decodeFromString>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putFloat(it.key, it.value) } + "string settings" -> Json.decodeFromString>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putString(it.key, it.value) } + "string set settings" -> Json.decodeFromString>>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putStringSet(it.key, it.value) } } } - // commit synchronously so that post-restore actions and a possible process kill - // (e.g. the user closing the app immediately after restore) don't lose data e.commit() return true } catch (e: Exception) { @@ -340,3 +499,70 @@ private val backupFilePatterns by lazy { listOf( "custom_font".toRegex(), "custom_emoji_font".toRegex(), ) } + +enum class BackupCategory { + LAYOUTS, + THEME_APPEARANCE, + DICTIONARY_HISTORY, + CLIPBOARD, + GENERAL_SETTINGS +} + +private fun getCategoryForPrefKey(key: String): BackupCategory { + if (key.startsWith("layout_")) return BackupCategory.LAYOUTS + + val themeKeys = setOf( + "theme_style", "icon_style", "theme_colors", "theme_colors_night", + "theme_key_borders", "theme_auto_day_night", "custom_icon_names", + "navbar_color", "font_scale", "emoji_font_scale", "narrow_key_gaps", + "narrow_key_gaps_level", "emoji_key_fit", "emoji_skin_tone", "space_bar_text" + ) + if (themeKeys.contains(key) + || key.startsWith("user_colors_") + || key.startsWith("user_all_colors_") + || key.startsWith("user_more_colors_") + || key.startsWith("keyboard_height_scale") + || key.startsWith("bottom_padding_scale") + || key.startsWith("side_padding_scale") + || key.startsWith("split_spacer_scale") + ) { + return BackupCategory.THEME_APPEARANCE + } + + val dictKeys = setOf( + "use_personalized_dicts", "block_potentially_offensive", "next_word_prediction", + "suggest_emojis", "inline_emoji_search", "show_emoji_descriptions", + "auto_correction", "more_auto_correction", "auto_correct_threshold", + "autocorrect_shortcuts", "backspace_reverts_autocorrect", "suggest_punctuation", + "add_to_personal_dictionary" + ) + if (dictKeys.contains(key)) return BackupCategory.DICTIONARY_HISTORY + + val clipboardKeys = setOf( + "enable_clipboard_history", "suggest_screenshots", "compress_screenshots", + "clipboard_history_retention_time", "clipboard_history_pinned_first", + "clipboard_fold_pinned", "clear_clipboard_icon" + ) + if (clipboardKeys.contains(key)) return BackupCategory.CLIPBOARD + + return BackupCategory.GENERAL_SETTINGS +} + +private fun getCategoryForFilePath(path: String): BackupCategory? { + if (path.startsWith("layouts${File.separator}") || path.contains("layouts/")) { + return BackupCategory.LAYOUTS + } + if (path.startsWith("custom_background_image") || path == "custom_font" || path == "custom_emoji_font" || path == FLOATING_KEYBOARD_PREFS_FILE_NAME) { + return BackupCategory.THEME_APPEARANCE + } + if (path.startsWith("dicts${File.separator}") || path.startsWith("dicts/") + || path.startsWith("blacklists${File.separator}") || path.startsWith("blacklists/") + || path.startsWith("UserHistoryDictionary") + ) { + return BackupCategory.DICTIONARY_HISTORY + } + if (path == Database.NAME) { + return BackupCategory.CLIPBOARD + } + return null +} diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt new file mode 100644 index 000000000..b6a4c454e --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.settings.preferences + +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import helium314.keyboard.latin.R +import helium314.keyboard.latin.handwriting.HandwritingLoader +import helium314.keyboard.settings.FeedbackManager +import helium314.keyboard.settings.dialogs.ConfirmationDialog +import helium314.keyboard.settings.filePicker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +@Composable +fun LoadHandwritingPluginPreference( + title: String, + summary: String? = null, + @DrawableRes icon: Int? = null, + onSuccess: (() -> Unit)? = null, +) { + var showDialog by rememberSaveable { mutableStateOf(false) } + var isDownloading by rememberSaveable { mutableStateOf(false) } + var remoteVersion by remember { mutableStateOf(null) } + var updateAvailable by remember { mutableStateOf(false) } + var isCheckingUpdate by remember { mutableStateOf(false) } + + val ctx = LocalContext.current + val scope = rememberCoroutineScope() + + val hasPlugin = HandwritingLoader.hasPlugin(ctx) + val localVersion = remember(hasPlugin) { HandwritingLoader.getPluginVersion(ctx) } + + LaunchedEffect(hasPlugin) { + isCheckingUpdate = true + scope.launch(Dispatchers.IO) { + try { + val url = URL("https://api.github.com/repos/LeanBitLab/Leantype-Handwriting-Plugin/releases/latest") + val conn = url.openConnection() as HttpURLConnection + conn.setRequestProperty("User-Agent", "HeliboardL") + conn.connect() + if (conn.responseCode == 200) { + val response = conn.inputStream.bufferedReader().use { it.readText() } + val regex = "\"tag_name\"\\s*:\\s*\"([^\"]+)\"".toRegex() + val match = regex.find(response) + if (match != null) { + val tag = match.groupValues[1] + remoteVersion = tag + if (hasPlugin && localVersion != null) { + updateAvailable = isUpdateAvailable(localVersion, tag) + } + } + } + } catch (e: Exception) { + // ignore network errors + } finally { + isCheckingUpdate = false + } + } + } + + val launcher = filePicker { uri -> + val success = HandwritingLoader.importPlugin(ctx, uri) + showDialog = false + if (success) { + FeedbackManager.message(ctx, R.string.load_handwriting_plugin_success) + onSuccess?.invoke() + } else { + FeedbackManager.message(ctx, R.string.load_handwriting_plugin_failed) + } + } + + fun startDownload() { + isDownloading = true + scope.launch(Dispatchers.IO) { + try { + val tag = remoteVersion ?: "latest" + val urlStr = if (tag == "latest") { + "https://github.com/LeanBitLab/Leantype-Handwriting-Plugin/releases/latest/download/handwriting_plugin.apk" + } else { + "https://github.com/LeanBitLab/Leantype-Handwriting-Plugin/releases/download/$tag/handwriting_plugin.apk" + } + var url = URL(urlStr) + var conn = url.openConnection() as HttpURLConnection + conn.instanceFollowRedirects = true + conn.setRequestProperty("User-Agent", "HeliboardL") + conn.connect() + + var redirectConn = conn + var status = redirectConn.responseCode + var redirectCount = 0 + while ((status == HttpURLConnection.HTTP_MOVED_TEMP || status == HttpURLConnection.HTTP_MOVED_PERM || status == HttpURLConnection.HTTP_SEE_OTHER) && redirectCount < 5) { + val newUrl = redirectConn.getHeaderField("Location") + redirectConn.disconnect() + val nextUrl = URL(newUrl) + redirectConn = nextUrl.openConnection() as HttpURLConnection + redirectConn.setRequestProperty("User-Agent", "HeliboardL") + redirectConn.connect() + status = redirectConn.responseCode + redirectCount++ + } + + if (status != HttpURLConnection.HTTP_OK) { + throw IOException("Server returned HTTP $status") + } + + val tempFile = File(ctx.cacheDir, "temp_handwriting_plugin.apk") + redirectConn.inputStream.use { input -> + FileOutputStream(tempFile).use { output -> + input.copyTo(output) + } + } + redirectConn.disconnect() + + val success = HandwritingLoader.importPlugin(ctx, Uri.fromFile(tempFile)) + tempFile.delete() + + withContext(Dispatchers.Main) { + isDownloading = false + if (success) { + FeedbackManager.message(ctx, R.string.load_handwriting_plugin_success) + onSuccess?.invoke() + showDialog = false + } else { + FeedbackManager.message(ctx, R.string.load_handwriting_plugin_failed) + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + isDownloading = false + Toast.makeText(ctx, "Download failed: ${e.localizedMessage}", Toast.LENGTH_LONG).show() + } + } + } + } + + Preference( + name = title, + description = summary, + icon = icon, + onClick = { showDialog = true } + ) + + if (showDialog) { + ConfirmationDialog( + onDismissRequest = { if (!isDownloading) showDialog = false }, + onConfirmed = { + if (!isDownloading) { + if (hasPlugin && !updateAvailable) { + HandwritingLoader.removePlugin(ctx) + FeedbackManager.message(ctx, "Handwriting plugin removed") + onSuccess?.invoke() + showDialog = false + } else { + startDownload() + } + } + }, + confirmButtonText = when { + isDownloading -> "Downloading..." + hasPlugin && !updateAvailable -> stringResource(R.string.load_handwriting_plugin_button_delete) + hasPlugin && updateAvailable -> "Update" + else -> "Download" + }, + title = { Text(stringResource(R.string.load_handwriting_plugin)) }, + content = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val message = when { + hasPlugin && updateAvailable -> "An update is available for the handwriting plugin!\nLocal version: $localVersion\nLatest version: $remoteVersion\n\nDo you want to download and update?" + hasPlugin -> "Handwriting plugin is active (version $localVersion).\n\nWarning: loading external code can be a security risk. Only use a plugin from a source you trust." + remoteVersion != null -> "Download the latest handwriting plugin (version $remoteVersion) from GitHub, or load an APK from local storage.\n\nWarning: loading external code can be a security risk. Only use a plugin from a source you trust." + else -> "Download the handwriting plugin from GitHub, or load an APK from local storage.\n\nWarning: loading external code can be a security risk. Only use a plugin from a source you trust." + } + Text(message) + if (isDownloading) { + Spacer(modifier = Modifier.height(16.dp)) + CircularProgressIndicator() + } + } + }, + neutralButtonText = when { + isDownloading -> null + hasPlugin && updateAvailable -> "Delete" + hasPlugin -> null + else -> "Load from file" + }, + onNeutral = { + if (hasPlugin) { + HandwritingLoader.removePlugin(ctx) + FeedbackManager.message(ctx, "Handwriting plugin removed") + onSuccess?.invoke() + showDialog = false + } else { + showDialog = false + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("*/*") + try { + launcher.launch(intent) + } catch (e: Exception) { + // ignore + } + } + } + ) + } +} + +private fun isUpdateAvailable(local: String, remote: String): Boolean { + val cleanLocal = local.removePrefix("v").trim() + val cleanRemote = remote.removePrefix("v").trim() + if (cleanLocal == cleanRemote) return false + + val localParts = cleanLocal.split(".").mapNotNull { it.toIntOrNull() } + val remoteParts = cleanRemote.split(".").mapNotNull { it.toIntOrNull() } + + val maxLength = maxOf(localParts.size, remoteParts.size) + for (i in 0 until maxLength) { + val localPart = localParts.getOrElse(i) { 0 } + val remotePart = remoteParts.getOrElse(i) { 0 } + if (remotePart > localPart) return true + if (localPart > remotePart) return false + } + return false +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt index f359f2eae..bc1cae203 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt @@ -79,6 +79,7 @@ private fun StandardAIIntegrationScreen(onClickBack: () -> Unit) { add(SettingsWithoutKey.HUGGINGFACE_TOKEN) add(SettingsWithoutKey.HUGGINGFACE_MODEL) add(SettingsWithoutKey.HUGGINGFACE_ENDPOINT) + add(SettingsWithoutKey.AI_ALLOW_INSECURE_CONNECTIONS) add(SettingsWithoutKey.GEMINI_TARGET_LANGUAGE) add(SettingsWithoutKey.TRANSLATE_HUGGINGFACE_MODEL) } @@ -95,6 +96,7 @@ private fun StandardAIIntegrationScreen(onClickBack: () -> Unit) { @Composable private fun OfflineAIIntegrationScreen(onClickBack: () -> Unit) { val items = listOf( + SettingsWithoutKey.CUSTOM_AI_KEYS, SettingsWithoutKey.OFFLINE_MODEL_PATH, SettingsWithoutKey.OFFLINE_KEEP_MODEL_LOADED ) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt index 133c5069f..6f03b8dcc 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt @@ -70,7 +70,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Spacer import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text @Composable @@ -455,6 +454,9 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( ) } }, + Setting(context, SettingsWithoutKey.AI_ALLOW_INSECURE_CONNECTIONS, R.string.ai_allow_insecure_connections_title, R.string.ai_allow_insecure_connections_summary) { setting -> + SwitchPreference(setting, Defaults.PREF_AI_ALLOW_INSECURE_CONNECTIONS) + }, Setting(context, SettingsWithoutKey.GEMINI_TARGET_LANGUAGE, R.string.translate_target_language_title, R.string.translate_target_language_summary) { setting -> val ctx = LocalContext.current val service = remember { helium314.keyboard.latin.utils.ProofreadService(ctx) } @@ -534,7 +536,7 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( ) } }, - if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised") Setting(context, SettingsWithoutKey.CUSTOM_AI_KEYS, R.string.custom_ai_keys_title, R.string.custom_ai_keys_summary) { + if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" || BuildConfig.FLAVOR == "offline") Setting(context, SettingsWithoutKey.CUSTOM_AI_KEYS, R.string.custom_ai_keys_title, R.string.custom_ai_keys_summary) { Preference( name = it.title, description = it.description, @@ -547,12 +549,10 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( if (BuildConfig.FLAVOR == "offline") Setting(context, SettingsWithoutKey.OFFLINE_MODEL_PATH, R.string.offline_model_title, R.string.offline_model_summary) { setting -> val context = LocalContext.current val service = remember { helium314.keyboard.latin.utils.ProofreadService(context) } - var encoderPath by remember { mutableStateOf(service.getModelPath()) } - var decoderPath by remember { mutableStateOf(service.getDecoderPath()) } - var tokenizerPath by remember { mutableStateOf(service.getTokenizerPath()) } + var modelPath by remember { mutableStateOf(service.getModelPath()) } - // Encoder file picker - val encoderLauncher = androidx.activity.compose.rememberLauncherForActivityResult( + // GGUF Model file picker + val modelLauncher = androidx.activity.compose.rememberLauncherForActivityResult( contract = androidx.activity.result.contract.ActivityResultContracts.OpenDocument() ) { uri -> uri?.let { @@ -562,77 +562,26 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( Log.e("AdvancedScreen", "Failed to take persistable permission", e) } service.setModelPath(it.toString()) - encoderPath = it.toString() - FeedbackManager.message(context, "Encoder selected") - } - } - - // Decoder file picker - val decoderLauncher = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.OpenDocument() - ) { uri -> - uri?.let { - try { - context.contentResolver.takePersistableUriPermission(it, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) - } catch (e: Exception) { - Log.e("AdvancedScreen", "Failed to take persistable permission", e) - } - service.setDecoderPath(it.toString()) - decoderPath = it.toString() - FeedbackManager.message(context, "Decoder selected") - } - } - - // Tokenizer file picker - val tokenizerLauncher = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.OpenDocument() - ) { uri -> - uri?.let { - try { - context.contentResolver.takePersistableUriPermission(it, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) - } catch (e: Exception) { - Log.e("AdvancedScreen", "Failed to take persistable permission", e) - } - service.setTokenizerPath(it.toString()) - tokenizerPath = it.toString() - FeedbackManager.message(context, "Tokenizer selected") + modelPath = it.toString() + FeedbackManager.message(context, "Model selected") } } androidx.compose.foundation.layout.Column { - // Encoder (required) - Preference( - name = "Encoder Model (.onnx)", - description = if (encoderPath != null) service.getModelName() else "Required - select encoder ONNX file", - onClick = { encoderLauncher.launch(arrayOf("application/octet-stream", "*/*")) } - ) - - // Decoder (required for generation) - Preference( - name = "Decoder Model (.onnx)", - description = if (decoderPath != null) "Selected" else "Required - select decoder ONNX file", - onClick = { decoderLauncher.launch(arrayOf("application/octet-stream", "*/*")) } - ) - - // Tokenizer (required for proper tokenization) Preference( - name = "Tokenizer (tokenizer.json)", - description = if (tokenizerPath != null) "Selected" else "Required - select tokenizer.json", - onClick = { tokenizerLauncher.launch(arrayOf("application/json", "*/*")) } + name = "GGUF Model (.gguf)", + description = if (modelPath != null) service.getModelName() else "Required - select local GGUF model file", + onClick = { modelLauncher.launch(arrayOf("application/octet-stream", "*/*")) } ) - if (encoderPath != null || decoderPath != null || tokenizerPath != null) { + if (modelPath != null) { Preference( - name = "Remove All Models", - description = "Unload models and free memory", + name = "Remove Model", + description = "Unload model and free memory", onClick = { service.unloadModel() service.setModelPath(null) - service.setDecoderPath(null) - service.setTokenizerPath(null) - encoderPath = null - decoderPath = null - tokenizerPath = null + modelPath = null } ) } @@ -657,7 +606,25 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( onClick = { showSystemPromptDialog = true } ) + var showTranslateSystemPromptDialog by remember { mutableStateOf(false) } + if (showTranslateSystemPromptDialog) { + TextInputDialog( + title = { Text("Translate System Instruction") }, + initialText = service.getTranslateSystemPrompt(), + checkTextValid = { true }, + onConfirmed = { + service.setTranslateSystemPrompt(it) + showTranslateSystemPromptDialog = false + }, + onDismissRequest = { showTranslateSystemPromptDialog = false } + ) + } + Preference( + name = "Translate Instruction", + description = service.getTranslateSystemPrompt().takeIf { it.isNotBlank() } ?: "Default", + onClick = { showTranslateSystemPromptDialog = true } + ) // Target Language for Translation val languageSetting = Setting(context, Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, R.string.translate_target_language_title, R.string.translate_target_language_summary) { } @@ -679,7 +646,48 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp) ) + // Temperature + SliderPreference( + name = stringResource(R.string.offline_temp_title), + key = Settings.PREF_OFFLINE_TEMP, + default = Defaults.PREF_OFFLINE_TEMP, + range = 0.0f..2.0f, + description = { String.format("%.2f", it) } + ) + + // Top-P + SliderPreference( + name = stringResource(R.string.offline_top_p_title), + key = Settings.PREF_OFFLINE_TOP_P, + default = Defaults.PREF_OFFLINE_TOP_P, + range = 0.0f..1.0f, + description = { String.format("%.2f", it) } + ) + // Top-K + SliderPreference( + name = stringResource(R.string.offline_top_k_title), + key = Settings.PREF_OFFLINE_TOP_K, + default = Defaults.PREF_OFFLINE_TOP_K, + range = 1.0f..100.0f, + description = { it.toString() } + ) + + // Min-P + SliderPreference( + name = stringResource(R.string.offline_min_p_title), + key = Settings.PREF_OFFLINE_MIN_P, + default = Defaults.PREF_OFFLINE_MIN_P, + range = 0.0f..1.0f, + description = { String.format("%.2f", it) } + ) + + // Show Thinking + val showThinkingSetting = Setting(context, Settings.PREF_OFFLINE_SHOW_THINKING, R.string.offline_show_thinking_title, R.string.offline_show_thinking_summary) { } + SwitchPreference( + setting = showThinkingSetting, + default = Defaults.PREF_OFFLINE_SHOW_THINKING + ) val tokenEntries = context.resources.getStringArray(R.array.offline_max_tokens_entries) val tokenValues = context.resources.getStringArray(R.array.offline_max_tokens_values).map { it.toInt() } diff --git a/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt new file mode 100644 index 000000000..e363c7db1 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.settings.screens + +import android.content.Context +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import helium314.keyboard.latin.R +import helium314.keyboard.latin.utils.Log +import helium314.keyboard.keyboard.KeyboardSwitcher +import helium314.keyboard.settings.DropDownField +import helium314.keyboard.settings.SearchScreen +import helium314.keyboard.settings.dialogs.ThreeButtonAlertDialog +import helium314.keyboard.settings.dialogs.ConfirmationDialog +import java.io.File +import java.io.IOException +import java.util.Locale + +private data class BlockedWord(val word: String, val locale: Locale) + +private fun getBlacklistFile(context: Context, locale: Locale): File { + val dir = File(context.filesDir, "blacklists") + if (!dir.exists()) dir.mkdirs() + return File(dir, "${locale.toLanguageTag()}.txt") +} + +private fun loadBlockedWords(context: Context): List { + val dir = File(context.filesDir, "blacklists") + if (!dir.exists() || !dir.isDirectory) return emptyList() + val list = mutableListOf() + dir.listFiles()?.forEach { file -> + if (file.isFile && file.name.endsWith(".txt")) { + val localeTag = file.name.substringBefore(".txt") + val locale = Locale.forLanguageTag(localeTag) + try { + file.readLines().forEach { line -> + val trimmed = line.trim().lowercase(locale) + if (trimmed.isNotEmpty()) { + list.add(BlockedWord(trimmed, locale)) + } + } + } catch (e: Exception) { + Log.e("BlockedWords", "Error reading blacklist file $file", e) + } + } + } + val uniqueList = list.distinct() + return uniqueList.sortedWith(compareBy({ it.word.lowercase() }, { it.locale.toLanguageTag() })) +} + +private fun addBlockedWord(context: Context, word: String, locale: Locale) { + val file = getBlacklistFile(context, locale) + val lowercaseWord = word.trim().lowercase(locale) + try { + val existing = if (file.exists()) file.readLines().map { it.trim().lowercase(locale) }.filter { it.isNotEmpty() }.toMutableSet() else mutableSetOf() + if (existing.add(lowercaseWord)) { + file.writeText(existing.joinToString("\n") + "\n") + } + } catch (e: Exception) { + Log.e("BlockedWords", "Error adding word to blacklist", e) + } +} + +private fun removeBlockedWord(context: Context, word: String, locale: Locale) { + val file = getBlacklistFile(context, locale) + val lowercaseWord = word.trim().lowercase(locale) + try { + if (file.exists()) { + val existing = file.readLines().map { it.trim().lowercase(locale) }.filter { it.isNotEmpty() }.toMutableSet() + if (existing.remove(lowercaseWord)) { + if (existing.isEmpty()) { + file.delete() + } else { + file.writeText(existing.joinToString("\n") + "\n") + } + } + } + } catch (e: Exception) { + Log.e("BlockedWords", "Error removing word from blacklist", e) + } +} + +private fun notifyKeyboardToReload() { + KeyboardSwitcher.getInstance().getLatinIME()?.getDictionaryFacilitator()?.reloadBlacklist() +} + +@Composable +fun BlockedWordsScreen( + onClickBack: () -> Unit, +) { + val ctx = LocalContext.current + var refreshTrigger by remember { mutableStateOf(0) } + val blockedWords = remember(refreshTrigger) { loadBlockedWords(ctx) } + var selectedWord: BlockedWord? by remember { mutableStateOf(null) } + var showClearAllDialog by remember { mutableStateOf(false) } + + Box(Modifier.fillMaxSize()) { + SearchScreen( + onClickBack = onClickBack, + title = { Text(stringResource(R.string.edit_blocked_words)) }, + menu = listOf( + stringResource(R.string.clear_all) to { showClearAllDialog = true } + ), + filteredItems = { term -> + blockedWords.filter { it.word.startsWith(term, true) } + }, + itemContent = { item -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .clickable { selectedWord = item } + .padding(vertical = 6.dp, horizontal = 16.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text(item.word, style = MaterialTheme.typography.bodyLarge) + Text( + item.locale.getLocaleDisplayNameForUserDictSettings(ctx), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + androidx.compose.material3.IconButton( + onClick = { + removeBlockedWord(ctx, item.word, item.locale) + notifyKeyboardToReload() + refreshTrigger++ + } + ) { + Icon( + painter = painterResource(R.drawable.ic_bin), + contentDescription = stringResource(R.string.delete) + ) + } + } + } + ) + ExtendedFloatingActionButton( + onClick = { selectedWord = BlockedWord("", getSortedDictionaryLocales().firstOrNull() ?: Locale.getDefault()) }, + text = { Text(stringResource(R.string.add_blocked_word)) }, + icon = { Icon(painter = painterResource(R.drawable.ic_plus), stringResource(R.string.add_blocked_word)) }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(all = 12.dp) + .then(Modifier.safeDrawingPadding()) + ) + } + + if (selectedWord != null) { + EditBlockedWordDialog(selectedWord!!, onDismissRequest = { + selectedWord = null + refreshTrigger++ + }) + } + + if (showClearAllDialog) { + ConfirmationDialog( + onDismissRequest = { showClearAllDialog = false }, + onConfirmed = { + showClearAllDialog = false + val dir = File(ctx.filesDir, "blacklists") + if (dir.exists() && dir.isDirectory) { + dir.listFiles()?.forEach { it.delete() } + } + notifyKeyboardToReload() + refreshTrigger++ + }, + content = { Text(stringResource(R.string.clear_all_blocked_words_confirmation)) } + ) + } +} + +@Composable +private fun EditBlockedWordDialog( + blockedWord: BlockedWord, + onDismissRequest: () -> Unit +) { + val ctx = LocalContext.current + val focusRequester = remember { FocusRequester() } + var wordText by remember { mutableStateOf(blockedWord.word) } + var wordLocale by remember { mutableStateOf(blockedWord.locale) } + + val localesList = remember { getSortedDictionaryLocales().toList() } + + val alreadyExists = remember(wordText, wordLocale) { + if (wordText.isBlank()) false + else { + val file = File(ctx.filesDir, "blacklists/${wordLocale.toLanguageTag()}.txt") + if (file.exists()) { + val cleanLower = wordText.trim().lowercase(wordLocale) + file.readLines().map { it.trim().lowercase(wordLocale) }.contains(cleanLower) + } else false + } + } + + val isNew = blockedWord.word.isEmpty() + val isSaveEnabled = wordText.isNotBlank() && (!alreadyExists || (!isNew && wordText.trim().lowercase(wordLocale) == blockedWord.word.lowercase(blockedWord.locale) && wordLocale == blockedWord.locale)) + + fun save() { + if (wordText.isNotBlank()) { + val cleanWord = wordText.trim() + if (!isNew && (blockedWord.word.lowercase(blockedWord.locale) != cleanWord.lowercase(wordLocale) || blockedWord.locale != wordLocale)) { + removeBlockedWord(ctx, blockedWord.word, blockedWord.locale) + } + addBlockedWord(ctx, cleanWord, wordLocale) + notifyKeyboardToReload() + } + } + + ThreeButtonAlertDialog( + onDismissRequest = onDismissRequest, + onConfirmed = { + save() + onDismissRequest() + }, + checkOk = { isSaveEnabled }, + confirmButtonText = stringResource(R.string.save), + neutralButtonText = if (isNew) null else stringResource(R.string.delete), + onNeutral = { + removeBlockedWord(ctx, blockedWord.word, blockedWord.locale) + notifyKeyboardToReload() + onDismissRequest() + }, + title = { + Text(if (isNew) stringResource(R.string.add_blocked_word) else stringResource(R.string.edit_blocked_words)) + }, + content = { + LaunchedEffect(blockedWord) { + if (isNew) { + focusRequester.requestFocus() + } + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TextField( + value = wordText, + onValueChange = { wordText = it }, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + singleLine = true, + label = { Text("Word") }, + keyboardActions = KeyboardActions { + if (isSaveEnabled) { + save() + onDismissRequest() + } + } + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.user_dict_settings_add_locale_option_name), Modifier.fillMaxWidth(0.3f)) + DropDownField( + items = localesList, + selectedItem = wordLocale, + onSelected = { wordLocale = it }, + ) { + Text(it.getLocaleDisplayNameForUserDictSettings(ctx)) + } + } + if (alreadyExists && (isNew || wordText != blockedWord.word || wordLocale != blockedWord.locale)) { + Text( + stringResource(R.string.blocked_word_already_present), + color = MaterialTheme.colorScheme.error + ) + } + } + } + ) +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt index 031b6116f..cdac0851d 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt @@ -64,7 +64,9 @@ fun DictionaryScreen( val enabledLanguages = SubtypeSettings.getEnabledSubtypes(true).map { it.locale().language } val cachedDictFolders = DictionaryInfoUtils.getCacheDirectories(ctx).map { it.name } val comparer = compareBy({ it.language !in enabledLanguages }, { it.toLanguageTag() !in cachedDictFolders }, { it.displayName }) - val dictionaryLocales = listOf(Locale(SubtypeLocaleUtils.NO_LANGUAGE)) + getDictionaryLocales(ctx).sortedWith(comparer) + val dictionaryLocales = listOf(Locale(SubtypeLocaleUtils.NO_LANGUAGE)) + getDictionaryLocales(ctx) + .filter { it.language != SubtypeLocaleUtils.NO_LANGUAGE } + .sortedWith(comparer) var selectedLocale: Locale? by remember { mutableStateOf(null) } var showAddDictDialog by remember { mutableStateOf(false) } val dictPicker = dictionaryFilePicker(selectedLocale) @@ -115,6 +117,23 @@ fun DictionaryScreen( } androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp)) + // Blocked Words Entry + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 16.dp) + .fillMaxWidth() + .clickable { SettingsDestination.navigateTo(SettingsDestination.BlockedWords) } + ) { + Text( + stringResource(R.string.edit_blocked_words), + style = MaterialTheme.typography.titleMedium + ) + NextScreenIcon() + } + androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp)) + // Blocklist Entry Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt index d5fc23b69..3c0d9fef8 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt @@ -27,9 +27,16 @@ import helium314.keyboard.settings.NextScreenIcon import helium314.keyboard.settings.SearchSettingsScreen import helium314.keyboard.settings.preferences.LoadGestureLibPreference import helium314.keyboard.settings.preferences.LoadEmojiLibPreference +import helium314.keyboard.settings.preferences.LoadHandwritingPluginPreference +import helium314.keyboard.latin.handwriting.HandwritingLoader +import helium314.keyboard.latin.BuildConfig import helium314.keyboard.latin.common.Links import helium314.keyboard.settings.preferences.Preference import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf @Composable fun LibrariesHubScreen( @@ -88,6 +95,17 @@ fun LibrariesHubScreen( icon = R.drawable.ic_emoji_smileys_emotion ) + // Handwriting Input Plugin + if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised") { + var handwritingInstalled by remember { mutableStateOf(HandwritingLoader.hasPlugin(context)) } + LoadHandwritingPluginPreference( + title = stringResource(R.string.libraries_hub_handwriting_title), + summary = if (handwritingInstalled) stringResource(R.string.libraries_status_active) else stringResource(R.string.libraries_status_not_installed), + icon = R.drawable.ic_edit, + onSuccess = { handwritingInstalled = HandwritingLoader.hasPlugin(context) } + ) + } + // Documentation & Features val uriHandler = LocalUriHandler.current Preference( diff --git a/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionaryScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionaryScreen.kt index 7559a50d6..a1c9138e9 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionaryScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionaryScreen.kt @@ -134,7 +134,10 @@ private fun EditWordDialog(word: Word, locale: Locale?, onDismissRequest: () -> val focusRequester = remember { FocusRequester() } var newWord by remember { mutableStateOf(word) } var newLocale by remember { mutableStateOf(locale) } - val wordValid = (newWord.word == word.word && locale == newLocale) || !doesWordExist(newWord.word, newLocale, ctx) + val identityUnchanged = newWord.word == word.word + && (newWord.shortcut.isNullOrEmpty() && word.shortcut.isNullOrEmpty() || newWord.shortcut == word.shortcut) + && locale == newLocale + val wordValid = identityUnchanged || !doesWordExist(newWord.word, newWord.shortcut, newLocale, ctx) fun save() { if (newWord != word || locale != newLocale) { deleteWord(word, locale, ctx.contentResolver) @@ -257,18 +260,27 @@ private fun deleteWord(wordDetails: Word, locale: Locale?, resolver: ContentReso } } -private fun doesWordExist(word: String, locale: Locale?, context: Context): Boolean { +private fun doesWordExist(word: String, shortcut: String?, locale: Locale?, context: Context): Boolean { val hasWordProjection = arrayOf(UserDictionary.Words.WORD, UserDictionary.Words.LOCALE) val select: String - val selectArgs: Array? + val selectArgs: Array if (locale == null) { - select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE} is null" - selectArgs = arrayOf(word) + if (shortcut.isNullOrEmpty()) { + select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE} is null AND (${UserDictionary.Words.SHORTCUT} is null OR ${UserDictionary.Words.SHORTCUT}='')" + selectArgs = arrayOf(word) + } else { + select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE} is null AND ${UserDictionary.Words.SHORTCUT}=?" + selectArgs = arrayOf(word, shortcut) + } } else { - select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE}=?" - // requires use of locale string (as opposed to more useful language tag) for interaction with Android system - selectArgs = arrayOf(word, locale.toString()) + if (shortcut.isNullOrEmpty()) { + select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE}=? AND (${UserDictionary.Words.SHORTCUT} is null OR ${UserDictionary.Words.SHORTCUT}='')" + selectArgs = arrayOf(word, locale.toString()) + } else { + select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE}=? AND ${UserDictionary.Words.SHORTCUT}=?" + selectArgs = arrayOf(word, locale.toString(), shortcut) + } } val cursor = context.contentResolver.query(UserDictionary.Words.CONTENT_URI, hasWordProjection, select, selectArgs, null) cursor.use { diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt index 68f842b42..59dbce68d 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt @@ -95,6 +95,7 @@ fun TextCorrectionScreen( Settings.PREF_SUGGEST_SCREENSHOTS, if (prefs.getBoolean(Settings.PREF_SUGGEST_SCREENSHOTS, Defaults.PREF_SUGGEST_SCREENSHOTS)) Settings.PREF_COMPRESS_SCREENSHOTS else null, + Settings.PREF_AUTO_READ_OTP, Settings.PREF_USE_CONTACTS, Settings.PREF_USE_APPS ) @@ -254,6 +255,25 @@ fun createCorrectionSettings(context: Context) = listOf( ) { SwitchPreference(it, Defaults.PREF_COMPRESS_SCREENSHOTS) }, + Setting(context, Settings.PREF_AUTO_READ_OTP, + R.string.auto_read_otp, R.string.auto_read_otp_summary + ) { setting -> + val activity = LocalContext.current.getActivity() ?: return@Setting + var granted by remember { mutableStateOf(PermissionsUtil.checkAllPermissionsGranted(activity, Manifest.permission.RECEIVE_SMS)) } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + granted = it + if (granted) + activity.prefs().edit { putBoolean(setting.key, true) } + } + SwitchPreference(setting, Defaults.PREF_AUTO_READ_OTP, + allowCheckedChange = { + if (it && !granted) { + launcher.launch(Manifest.permission.RECEIVE_SMS) + false + } else true + } + ) + }, Setting(context, Settings.PREF_USE_CONTACTS, R.string.use_contacts_dict, R.string.use_contacts_dict_summary ) { setting -> diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt index e8a1a8014..5896fdd11 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt @@ -79,6 +79,10 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { mutableStateOf(TextExpanderUtils.isEnabled(context)) } + var isImmediateEnabled by remember { + mutableStateOf(TextExpanderUtils.isImmediateEnabled(context)) + } + var shortcutsMap by remember { mutableStateOf(TextExpanderUtils.getShortcuts(context)) } @@ -89,6 +93,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { var editingShortcut by remember { mutableStateOf("") } var editingTemplate by remember { mutableStateOf(TextFieldValue("")) } var originalShortcutToEdit by remember { mutableStateOf(null) } + var editingIsRegex by remember { mutableStateOf(false) } Box(modifier = Modifier.fillMaxSize()) { SearchScreen( @@ -104,7 +109,10 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { filteredItems = { term -> shortcutsMap.entries .filter { (shortcut, template) -> - shortcut.contains(term, ignoreCase = true) || + val displayShortcut = if (shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX)) { + shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) + } else shortcut + displayShortcut.contains(term, ignoreCase = true) || template.contains(term, ignoreCase = true) } .map { Pair(it.key, it.value) } @@ -116,9 +124,11 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { template = template, prefix = prefixText, onEdit = { - editingShortcut = shortcut + val isRegex = shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX) + editingShortcut = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut editingTemplate = TextFieldValue(template) originalShortcutToEdit = shortcut + editingIsRegex = isRegex showAddDialog = true }, onDelete = { @@ -305,6 +315,15 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { onCheckedChange = { isExpanderEnabled = it } ) + SwitchPreference( + name = "Expand immediately", + key = TextExpanderUtils.PREF_IMMEDIATE, + default = false, + description = "Expand shortcuts immediately without pressing space.", + enabled = isExpanderEnabled, + onCheckedChange = { isImmediateEnabled = it } + ) + // 2. Custom Prefix Configuration OutlinedTextField( value = prefixText, @@ -382,9 +401,11 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { template = template, prefix = prefixText, onEdit = { - editingShortcut = shortcut + val isRegex = shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX) + editingShortcut = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut editingTemplate = TextFieldValue(template) originalShortcutToEdit = shortcut + editingIsRegex = isRegex showAddDialog = true }, onDelete = { @@ -409,6 +430,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { editingShortcut = "" editingTemplate = TextFieldValue("") originalShortcutToEdit = null + editingIsRegex = false showAddDialog = true }, text = { Text("Add Shortcut") }, @@ -425,20 +447,28 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { if (showAddDialog) { val focusRequester = remember { FocusRequester() } val isEditMode = originalShortcutToEdit != null + val isRegexValid = remember(editingShortcut, editingIsRegex) { + !editingIsRegex || runCatching { Regex(editingShortcut.trim()) }.isSuccess + } ThreeButtonAlertDialog( onDismissRequest = { showAddDialog = false }, onConfirmed = { val updated = shortcutsMap.toMutableMap() - if (isEditMode && originalShortcutToEdit != editingShortcut) { + if (isEditMode) { updated.remove(originalShortcutToEdit) } - updated[editingShortcut.trim()] = editingTemplate.text + val key = if (editingIsRegex) { + TextExpanderUtils.REGEX_PREFIX + editingShortcut.trim() + } else { + editingShortcut.trim() + } + updated[key] = editingTemplate.text shortcutsMap = updated TextExpanderUtils.saveShortcuts(context, updated) showAddDialog = false }, - checkOk = { editingShortcut.trim().isNotEmpty() && editingTemplate.text.isNotEmpty() }, + checkOk = { editingShortcut.trim().isNotEmpty() && editingTemplate.text.isNotEmpty() && isRegexValid }, confirmButtonText = if (isEditMode) "Save" else "Add", neutralButtonText = if (isEditMode) "Delete" else null, onNeutral = { @@ -460,13 +490,41 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { TextField( value = editingShortcut, - onValueChange = { editingShortcut = it.replace(" ", "") }, + onValueChange = { editingShortcut = if (editingIsRegex) it else it.replace(" ", "") }, modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester), singleLine = true, - label = { Text("Shortcut (e.g. 'brb', 'em')") } + label = { Text(if (editingIsRegex) "Regex Pattern (e.g. '(\\d+)usd')" else "Shortcut (e.g. 'brb', 'em')") } ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Regular Expression", + style = MaterialTheme.typography.bodyMedium + ) + androidx.compose.material3.Switch( + checked = editingIsRegex, + onCheckedChange = { checked -> + editingIsRegex = checked + if (!checked) { + editingShortcut = editingShortcut.replace(" ", "") + } + } + ) + } + + if (editingIsRegex && !isRegexValid) { + Text( + text = "⚠️ Invalid regular expression pattern", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } OutlinedTextField( value = editingTemplate, @@ -538,6 +596,9 @@ private fun ShortcutItem( onEdit: () -> Unit, onDelete: () -> Unit ) { + val isRegex = shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX) + val displayShortcut = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut + ElevatedCard( modifier = Modifier .fillMaxWidth() @@ -556,7 +617,10 @@ private fun ShortcutItem( horizontalArrangement = Arrangement.SpaceBetween ) { Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { Box( modifier = Modifier .clip(RoundedCornerShape(8.dp)) @@ -564,13 +628,28 @@ private fun ShortcutItem( .padding(horizontal = 8.dp, vertical = 4.dp) ) { Text( - text = "$prefix$shortcut", + text = if (isRegex) displayShortcut else "$prefix$displayShortcut", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace ) } + if (isRegex) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = "Regex", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + fontWeight = FontWeight.Bold + ) + } + } } Spacer(modifier = Modifier.height(8.dp)) Text( diff --git a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt index 0b6d128d8..215331ce8 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.Surface +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -38,6 +39,7 @@ import helium314.keyboard.latin.utils.dpToPx import helium314.keyboard.latin.utils.getActivity import helium314.keyboard.latin.utils.getStringResourceOrName import helium314.keyboard.latin.utils.prefs +import helium314.keyboard.latin.utils.upgradeToolbarPrefs import helium314.keyboard.settings.SearchSettingsScreen import helium314.keyboard.settings.Setting import helium314.keyboard.settings.SettingsActivity @@ -54,8 +56,12 @@ import helium314.keyboard.settings.previewDark fun ToolbarScreen( onClickBack: () -> Unit, ) { - val prefs = LocalContext.current.prefs() - val b = (LocalContext.current.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState() + val context = LocalContext.current + val prefs = context.prefs() + LaunchedEffect(Unit) { + upgradeToolbarPrefs(prefs) + } + val b = (context.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState() if ((b?.value ?: 0) < 0) Log.v("irrelevant", "stupid way to trigger recomposition on preference change") val toolbarMode = Settings.readToolbarMode(prefs) @@ -93,7 +99,8 @@ fun createToolbarSettings(context: Context): List { val filter = { name: String -> val lowerName = name.lowercase() when { - lowerName.startsWith("custom_ai_") -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" + lowerName.startsWith("custom_ai_") -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" || BuildConfig.FLAVOR == "offline" || BuildConfig.FLAVOR == "standardOptimised" + lowerName == "handwriting" -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" lowerName in listOf("proofread", "translate", "clipboard_search") -> BuildConfig.FLAVOR != "offlinelite" else -> true } diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml index b88909504..64aa3961a 100644 --- a/app/src/main/res/drawable/ic_edit.xml +++ b/app/src/main/res/drawable/ic_edit.xml @@ -8,6 +8,6 @@ android:viewportWidth="24" android:viewportHeight="24"> \ No newline at end of file diff --git a/app/src/main/res/layout/handwriting_view.xml b/app/src/main/res/layout/handwriting_view.xml new file mode 100644 index 000000000..86b475c83 --- /dev/null +++ b/app/src/main/res/layout/handwriting_view.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/main_keyboard_frame.xml b/app/src/main/res/layout/main_keyboard_frame.xml index f4ef48811..5ce124c2e 100644 --- a/app/src/main/res/layout/main_keyboard_frame.xml +++ b/app/src/main/res/layout/main_keyboard_frame.xml @@ -35,6 +35,9 @@ + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82e563c1c..97a09e119 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -142,6 +142,11 @@ Capitalize the first word of each sentence Personal dictionary + Blocked words + Manage words blocked from suggestions + Block a word + Word is already blocked + Are you sure you want to unblock all words? Main dictionary @@ -204,6 +209,12 @@ Suggest recent screenshots Show recently taken screenshots as a suggestion + + Auto-read OTP from SMS + + Show the code from an incoming SMS as a suggestion you can tap to insert + + Dismiss OTP suggestion Compress screenshot suggestions @@ -442,6 +453,18 @@ Download complete! Restarting… Download failed: %s + + Load handwriting plugin + Provide an APK plugin to enable handwriting input + Provide a handwriting plugin APK.\n\nWarning: loading external code can be a security risk. Only use a plugin from a source you trust. + Load plugin APK + Delete plugin + Handwriting plugin imported successfully + Failed to load handwriting plugin APK + Handwriting + Handwriting plugin required + Please load the handwriting plugin library to enable drawing recognition. + Load Plugin Autospace after punctuation @@ -611,6 +634,15 @@ API Endpoint API endpoint URL https://api.groq.com/openai/v1/chat/completions + Insecure HTTP connection blocked. Enable \'Allow Insecure Connections\' in AI settings. + Allow insecure connections + Allow HTTP connections and ignore SSL certificate errors. Warning: exposes input to local network eavesdropping. + Layouts + Theme & Custom Backgrounds + Dictionaries & Typing History + Clipboard History + General Settings + Select items to include: https://generativelanguage.googleapis.com/v1beta/models?key=%1$s https://api.groq.com/openai/v1/models @@ -1179,6 +1211,7 @@ New dictionary: Gesture Typing Library Main Dictionaries Emoji Libraries + Handwriting Input Plugin Dictionary Loading Guide To add a new dictionary:\n1. Obtain a .dict file for your language.\n2. Tap \"Manage Dictionaries\".\n3. Use the plus (+) button to import the file. Active @@ -1369,6 +1402,16 @@ New dictionary: Manage local LLM Offline AI Max Tokens Maximum length of the generated correction + Temperature + Controls randomness: lower is more focused and deterministic + Top-P (Nucleus Sampling) + Controls diversity of vocabulary: lower cuts off less likely options + Top-K + Limits choices to K most probable tokens + Min-P + Minimum probability threshold relative to most likely token + Show Thinking + Display reasoning process and internal thinking tokens if the model generates them diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..4d5bef7d4 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt index e7d30bf4e..daed7186c 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt @@ -34,6 +34,19 @@ object ProofreadHelper { var lastOriginalText: String? = null private set + /** + * Preload the model in the background to avoid initial latency. + */ + @JvmStatic + fun preloadModel(context: Context) { + val service = ProofreadService(context) + val modelPath = service.getModelPath() + if (modelPath.isNullOrBlank()) return + scope.launch { + ProofreadService.ModelHolder.loadModel(context, modelPath) + } + } + /** * Cancel the current proofreading/translation operation if one is in progress. */ @@ -206,7 +219,7 @@ object ProofreadHelper { text = text, noTextErrorResId = R.string.proofread_no_text, errorResId = R.string.proofread_error, - apiCall = { service -> service.proofread(text, overridePrompt = prompt) }, + apiCall = { service -> service.proofread(text, overridePrompt = prompt, showThinking = showThinking) }, onSuccess = onSuccess, onError = onError ) diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt index 57d1126e4..33318f2fd 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -9,9 +9,6 @@ import android.content.SharedPreferences import android.net.Uri import android.provider.OpenableColumns import android.util.Log -import ai.onnxruntime.OnnxTensor -import ai.onnxruntime.OrtEnvironment -import ai.onnxruntime.OrtSession import helium314.keyboard.latin.settings.Defaults import helium314.keyboard.latin.settings.Settings import kotlinx.coroutines.Dispatchers @@ -20,62 +17,66 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.nehuatl.llamacpp.LlamaHelper import java.io.File -import java.io.FileOutputStream -import java.nio.FloatBuffer -import java.nio.LongBuffer /** - * Offline proofreading service using ONNX Runtime with T5 grammar correction models. - * - * T5 uses encoder-decoder architecture: - * 1. Encoder: Processes input text → encoder hidden states - * 2. Decoder: Uses hidden states to generate corrected text token by token - * + * Offline proofreading service using llamacpp-kotlin with GGUF models. + * + * Uses LlamaHelper for on-device inference with llama.cpp backend. + * Supports any GGUF model for text correction/generation. + * * Expected model files: - * - encoder_model_quant.onnx - * - init_decoder_quant.onnx (initial decoder) - * - tokenizer.json (T5 vocabulary) + * - Any GGUF format model file */ class ProofreadService(private val context: Context) { - val prefs: SharedPreferences by lazy { + val sharedPrefs: SharedPreferences by lazy { context.prefs() } + val prefs: SharedPreferences get() = sharedPrefs + // Singleton holder for model state to prevent reloading on every request object ModelHolder { - var ortEnvironment: OrtEnvironment? = null - var encoderSession: OrtSession? = null - var decoderSession: OrtSession? = null - var currentEncoderPath: String? = null - var currentDecoderPath: String? = null - var tokenizer: T5Tokenizer? = null + var llamaHelper: LlamaHelper? = null + var currentModelPath: String? = null var isModelAvailable: Boolean = true - private var modelDir: File? = null + var isModelLoaded: Boolean = false // Smart Unload Logic private var unloadJob: Job? = null private val scope = CoroutineScope(kotlinx.coroutines.SupervisorJob() + Dispatchers.IO) private const val UNLOAD_DELAY_MS = 10 * 60 * 1000L // 10 minutes + private val loadMutex = Mutex() + + // Flow for LLM events + val llmFlow = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) @Synchronized - fun scheduleUnload(context: Context) { // Context required to check prefs + fun scheduleUnload(context: Context) { unloadJob?.cancel() - // Check preference val prefs = context.prefs() val keepLoaded = prefs.getBoolean(Settings.PREF_OFFLINE_KEEP_MODEL_LOADED, Defaults.PREF_OFFLINE_KEEP_MODEL_LOADED) if (keepLoaded) { - Log.i("OnnxProofreadService", "Model unload skipped (Keep Model Loaded enabled)") + Log.i(TAG, "Model unload skipped (Keep Model Loaded enabled)") return } unloadJob = scope.launch { delay(UNLOAD_DELAY_MS) unloadModel() - Log.i("OnnxProofreadService", "Offline AI model unloaded due to inactivity") + Log.i(TAG, "Offline AI model unloaded due to inactivity") } } @@ -88,103 +89,115 @@ class ProofreadService(private val context: Context) { @Synchronized fun unloadModel() { try { - encoderSession?.close() - decoderSession?.close() - ortEnvironment?.close() + llamaHelper?.release() } catch (e: Exception) { - Log.w("OnnxProofreadService", "Error closing ONNX sessions", e) + Log.w(TAG, "Error unloading llama model", e) } - encoderSession = null - decoderSession = null - ortEnvironment = null - currentEncoderPath = null - currentDecoderPath = null - tokenizer = null - isModelAvailable = true // Reset availability flag on unload + llamaHelper = null + currentModelPath = null + isModelLoaded = false + isModelAvailable = true } - @Synchronized - fun loadModel( + suspend fun loadModel( context: Context, - encoderPath: String, - decoderPath: String?, - tokenizerPath: String? - ): Boolean { - cancelUnload() // Cancel any pending unload since we are loading/using it - - // Check if already loaded with same paths - if (encoderSession != null && currentEncoderPath == encoderPath && - (decoderPath.isNullOrBlank() || (decoderSession != null && currentDecoderPath == decoderPath))) { + modelPath: String + ): Boolean = loadMutex.withLock { + cancelUnload() + + // Check if already loaded with same path + if (isModelLoaded && currentModelPath == modelPath && llamaHelper != null) { return true } - unloadModel() // Ensure clean slate if paths changed + unloadModel() // Ensure clean slate if path changed return try { - // Create model cache directory - modelDir = File(context.cacheDir, "onnx_model") - modelDir!!.mkdirs() - - // Initialize tokenizer - tokenizer = T5Tokenizer() - if (!tokenizerPath.isNullOrBlank()) { - val tokenizerFile = copyUriToCache(context, Uri.parse(tokenizerPath), "tokenizer.json", modelDir!!) - if (tokenizerFile != null) { - tokenizer!!.loadVocab(tokenizerFile) + val contentResolver = context.contentResolver + val helper = LlamaHelper( + contentResolver, + scope, + llmFlow + ) + + // Get llama via reflection + val llamaField = LlamaHelper::class.java.getDeclaredField("llama\$delegate").apply { isAccessible = true } + val llamaLazy = llamaField.get(helper) as Lazy + val llama = llamaLazy.value + + // Detach model file descriptor + val uri = android.net.Uri.parse(modelPath) + val pfd = contentResolver.openFileDescriptor(uri, "r") + ?: throw IllegalArgumentException("Failed to open model file descriptor") + val modelFd = pfd.detachFd() + + // Calculate optimal threads count (4 threads is the sweet spot for mobile CPUs) + val cores = Runtime.getRuntime().availableProcessors() + val threads = if (cores <= 4) cores else 4 + + Log.i(TAG, "Loading GGUF model: threads=$threads (cores=$cores), use_mmap=false") + + // Construct parameters map + val params = mutableMapOf( + "model" to modelPath, + "model_fd" to modelFd, + "use_mmap" to false, + "use_mlock" to false, + "n_ctx" to 2048, + "embedding" to false, + "n_batch" to 512, + "n_threads" to threads, + "n_gpu_layers" to 0, + "vocab_only" to false, + "lora" to "", + "lora_scaled" to 1.0, + "rope_freq_base" to 0.0, + "rope_freq_scale" to 0.0 + ) + + // JNI callback called by native code for each token + val callback: (String) -> Unit = { word -> + try { + val allTextField = LlamaHelper::class.java.getDeclaredField("allText").apply { isAccessible = true } + val currentAllText = allTextField.get(helper) as String + allTextField.set(helper, currentAllText + word) + + val tokenCountField = LlamaHelper::class.java.getDeclaredField("tokenCount").apply { isAccessible = true } + val currentCount = tokenCountField.get(helper) as Int + tokenCountField.set(helper, currentCount + 1) + + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Ongoing(word, currentCount + 1)) + } catch (e: Throwable) { + Log.e(TAG, "Error in native token callback", e) } } - // Initialize ONNX Runtime - ortEnvironment = OrtEnvironment.getEnvironment() - val sessionOptions = OrtSession.SessionOptions().apply { - setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT) - setIntraOpNumThreads(4) - } + // Start the engine + val result = llama.startEngine(params, callback) - // Copy and load encoder - val encoderFile = copyUriToCache(context, Uri.parse(encoderPath), "encoder.onnx", modelDir!!) - if (encoderFile == null) { - Log.e("OnnxProofreadService", "Failed to copy encoder") - return false - } + val contextId = result?.get("contextId") as? Int + ?: throw IllegalStateException("contextId not found in result map") - encoderSession = ortEnvironment!!.createSession(encoderFile.absolutePath, sessionOptions) - currentEncoderPath = encoderPath + // Set currentContext via reflection + val currentContextField = LlamaHelper::class.java.getDeclaredField("currentContext").apply { isAccessible = true } + currentContextField.set(helper, contextId) - // Copy and load decoder if provided - if (!decoderPath.isNullOrBlank()) { - val decoderFile = copyUriToCache(context, Uri.parse(decoderPath), "decoder.onnx", modelDir!!) - if (decoderFile != null) { - decoderSession = ortEnvironment!!.createSession(decoderFile.absolutePath, sessionOptions) - currentDecoderPath = decoderPath - } - } - + // Emit Loaded event + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Loaded(modelPath)) + + llamaHelper = helper + currentModelPath = modelPath + isModelLoaded = true isModelAvailable = true true } catch (e: Throwable) { - Log.e("OnnxProofreadService", "Failed to load ONNX models", e) + Log.e(TAG, "Failed to load GGUF model", e) isModelAvailable = false false } } - private fun copyUriToCache(context: Context, uri: Uri, targetName: String, dir: File): File? { - val targetFile = File(dir, targetName) - if (targetFile.exists() && targetFile.length() > 0) return targetFile - - return try { - context.contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(targetFile).use { output -> - input.copyTo(output) - } - } - if (targetFile.exists() && targetFile.length() > 0) targetFile else null - } catch (e: Exception) { - Log.e("OnnxProofreadService", "Failed to copy $targetName", e) - null - } - } + private const val TAG = "LlamaProofreadService" } // AI Provider support (API compatibility) @@ -216,26 +229,26 @@ class ProofreadService(private val context: Context) { fun getGroqModel(): String = "Offline Mode" fun setGroqModel(model: String) { /* No-op */ } - // Model management - encoder path - fun getModelPath(): String? = prefs.getString(KEY_ENCODER_PATH, null) + // Model management - single model path (no encoder/decoder split) + fun getModelPath(): String? = sharedPrefs.getString(KEY_MODEL_PATH, null) fun setModelPath(path: String?) { - prefs.edit().apply { + sharedPrefs.edit().apply { if (path.isNullOrBlank()) { - remove(KEY_ENCODER_PATH) + remove(KEY_MODEL_PATH) } else { - putString(KEY_ENCODER_PATH, path) + putString(KEY_MODEL_PATH, path) } apply() } ModelHolder.unloadModel() } - // Decoder path (separate setting) - fun getDecoderPath(): String? = prefs.getString(KEY_DECODER_PATH, null) + // Decoder path (kept for API compatibility, not used with llamacpp) + fun getDecoderPath(): String? = sharedPrefs.getString(KEY_DECODER_PATH, null) fun setDecoderPath(path: String?) { - prefs.edit().apply { + sharedPrefs.edit().apply { if (path.isNullOrBlank()) { remove(KEY_DECODER_PATH) } else { @@ -243,14 +256,13 @@ class ProofreadService(private val context: Context) { } apply() } - ModelHolder.unloadModel() } - // Tokenizer path (vocabulary file) - fun getTokenizerPath(): String? = prefs.getString(KEY_TOKENIZER_PATH, null) + // Tokenizer path (not needed with GGUF - tokenizer is embedded) + fun getTokenizerPath(): String? = sharedPrefs.getString(KEY_TOKENIZER_PATH, null) fun setTokenizerPath(path: String?) { - prefs.edit().apply { + sharedPrefs.edit().apply { if (path.isNullOrBlank()) { remove(KEY_TOKENIZER_PATH) } else { @@ -258,14 +270,18 @@ class ProofreadService(private val context: Context) { } apply() } - ModelHolder.unloadModel() - ModelHolder.tokenizer = null } - fun getSystemPrompt(): String = prefs.getString(Settings.PREF_OFFLINE_SYSTEM_PROMPT, "") ?: "" + fun getSystemPrompt(): String = sharedPrefs.getString(Settings.PREF_OFFLINE_SYSTEM_PROMPT, "") ?: "" fun setSystemPrompt(prompt: String) { - prefs.edit().putString(Settings.PREF_OFFLINE_SYSTEM_PROMPT, prompt).apply() + sharedPrefs.edit().putString(Settings.PREF_OFFLINE_SYSTEM_PROMPT, prompt).apply() + } + + fun getTranslateSystemPrompt(): String = sharedPrefs.getString(Settings.PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT, "") ?: "" + + fun setTranslateSystemPrompt(prompt: String) { + sharedPrefs.edit().putString(Settings.PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT, prompt).apply() } fun getModelName(): String { @@ -310,34 +326,31 @@ class ProofreadService(private val context: Context) { } /** - * Copy a content URI to cache and return the local file path. - */ - - - /** - * Run T5 encoder-decoder inference for grammar correction. - */ - /** - * Run T5 encoder-decoder inference for translation. + * Run llamacpp inference for translation. */ suspend fun translate(text: String): Result { - val target = prefs.getString(Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE) ?: Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE - // T5 standard prefix for translation - val prompt = "translate English to $target: " - return proofread(text, overridePrompt = prompt) + val target = sharedPrefs.getString(Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE) ?: Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE + val systemPromptTemplate = getTranslateSystemPrompt().takeIf { it.isNotBlank() } ?: Defaults.PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT + val prompt = systemPromptTemplate.replace("{lang}", target) + return proofread(text, overridePrompt = prompt, targetLanguage = target) } /** - * Run T5 encoder-decoder inference. + * Run llamacpp inference for proofreading/text correction. */ - suspend fun proofread(text: String, overridePrompt: String? = null): Result = withContext(Dispatchers.IO) { - val encoderPath = getModelPath() - if (encoderPath.isNullOrBlank()) { - return@withContext Result.failure(ProofreadException("Model not loaded. Please select encoder ONNX file.")) + suspend fun proofread( + text: String, + overridePrompt: String? = null, + showThinking: Boolean? = null, + targetLanguage: String? = null + ): Result = withContext(Dispatchers.IO) { + val modelPath = getModelPath() + if (modelPath.isNullOrBlank()) { + return@withContext Result.failure(ProofreadException("Model not loaded. Please select a GGUF model file.")) } // Load model (or get cached) - if (!ModelHolder.loadModel(context, encoderPath, getDecoderPath(), getTokenizerPath())) { + if (!ModelHolder.loadModel(context, modelPath)) { Log.e(TAG, "Model load failed") return@withContext Result.failure(ProofreadException("Failed to load model.")) } @@ -346,343 +359,296 @@ class ProofreadService(private val context: Context) { ModelHolder.cancelUnload() try { - val maxTokens = prefs.getInt(Settings.PREF_OFFLINE_MAX_TOKENS, Defaults.PREF_OFFLINE_MAX_TOKENS) - - // 1. Tokenize input - val prompt = overridePrompt ?: getSystemPrompt() - val inputText = if (prompt.isNotBlank()) "$prompt$text" else text - val inputIds = ModelHolder.tokenizer!!.encode(inputText, addPrefix = false) + val maxTokens = sharedPrefs.getInt(Settings.PREF_OFFLINE_MAX_TOKENS, Defaults.PREF_OFFLINE_MAX_TOKENS) + val temp = sharedPrefs.getFloat(Settings.PREF_OFFLINE_TEMP, Defaults.PREF_OFFLINE_TEMP) + val topP = sharedPrefs.getFloat(Settings.PREF_OFFLINE_TOP_P, Defaults.PREF_OFFLINE_TOP_P) + val topK = sharedPrefs.getInt(Settings.PREF_OFFLINE_TOP_K, Defaults.PREF_OFFLINE_TOP_K) + val minP = sharedPrefs.getFloat(Settings.PREF_OFFLINE_MIN_P, Defaults.PREF_OFFLINE_MIN_P) + val showThinkingVal = showThinking ?: sharedPrefs.getBoolean(Settings.PREF_OFFLINE_SHOW_THINKING, Defaults.PREF_OFFLINE_SHOW_THINKING) - val batchSize = 1L - val seqLen = inputIds.size.toLong() - val inputShape = longArrayOf(batchSize, seqLen) - - // 2. Create input tensors - val inputTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, LongBuffer.wrap(inputIds), inputShape) - val attentionMask = LongArray(inputIds.size) { 1L } - val attentionTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, LongBuffer.wrap(attentionMask), inputShape) + // Build the prompt + val systemPrompt = overridePrompt ?: getSystemPrompt() + val fullPrompt = if (systemPrompt.contains("{text}")) { + systemPrompt.replace("{text}", text) + } else if (overridePrompt != null) { + // Translation or specific override + val examples = targetLanguage?.let { getTranslationFewShot(it) } ?: emptyList() + if (examples.isNotEmpty()) { + var builder = "Instruction: ${systemPrompt.trim()}\n\n" + for (ex in examples) { + builder += "Input: ${ex.first}\nOutput: ${ex.second}\n\n" + } + builder += "Input: $text\nOutput:" + builder + } else { + "Instruction: ${systemPrompt.trim()}\n\nInput: $text\nOutput:" + } + } else { + // Default proofreading with few-shot examples for better local model guidance + val instruction = systemPrompt.ifBlank { "Correct the grammar and spelling of the input text. Output only the corrected text, nothing else." } + "Instruction: ${instruction.trim()}\n\n" + + "Input: heko hw r u\n" + + "Output: Hello, how are you?\n\n" + + "Input: what you name\n" + + "Output: What is your name?\n\n" + + "Input: $text\n" + + "Output:" + } - // 3. Run encoder - val encoderInputs = mapOf( - "input_ids" to inputTensor, - "attention_mask" to attentionTensor + // Collect generated text from the flow + val generatedText = StringBuilder() + val helper = ModelHolder.llamaHelper + ?: return@withContext Result.failure(ProofreadException("Model not available")) + + // Use predict with custom parameters + predictWithParams( + helper = helper, + prompt = fullPrompt, + temp = temp, + topP = topP, + topK = topK, + minP = minP, + maxTokens = maxTokens, + showThinking = showThinkingVal ) + // Collect events until done + ModelHolder.llmFlow.takeWhile { event -> + when (event) { + is LlamaHelper.LLMEvent.Ongoing -> { + generatedText.append(event.word) + true + } + is LlamaHelper.LLMEvent.Done -> { + false + } + is LlamaHelper.LLMEvent.Error -> { + throw ProofreadException(event.toString()) + } + else -> true + } + }.collect {} + + // Schedule unload after work is done + ModelHolder.scheduleUnload(context) + + val output = generatedText.toString().trim() + + // Robust cleaning of the generated output + var cleanedOutput = output + if (cleanedOutput.startsWith(fullPrompt, ignoreCase = true)) { + cleanedOutput = cleanedOutput.substring(fullPrompt.length).trim() + } else if (systemPrompt.isNotBlank() && cleanedOutput.startsWith(systemPrompt, ignoreCase = true)) { + cleanedOutput = cleanedOutput.substring(systemPrompt.length).trim() + if (cleanedOutput.startsWith(text, ignoreCase = true)) { + cleanedOutput = cleanedOutput.substring(text.length).trim() + } + } - val startTime = System.currentTimeMillis() - val encoderResults = ModelHolder.encoderSession!!.run(encoderInputs) - val encoderTime = System.currentTimeMillis() - startTime + // Truncate at the first occurrence of subsequent template markers + val markers = listOf("\nInput:", "\nInstruction:", "\nOutput:", "\nCorrected:", "Input:", "Instruction:", "Output:", "Corrected:") + for (marker in markers) { + val idx = cleanedOutput.indexOf(marker, ignoreCase = true) + if (idx != -1) { + if (marker.startsWith("\n") || idx > 0) { + cleanedOutput = cleanedOutput.substring(0, idx).trim() + } + } + } - // Get encoder hidden states - val encoderOutput = encoderResults[0] - val hiddenStates = encoderOutput.value // [batch, seq, hidden_dim] + // Also truncate at any newline followed by a potential template header (e.g., "\nDraft email:", "\nCorrection:") + val headerRegex = Regex("\\n[a-zA-Z0-9 ]+:") + val match = headerRegex.find(cleanedOutput) + if (match != null) { + cleanedOutput = cleanedOutput.substring(0, match.range.first).trim() + } - // 4. Run decoder (if available) - val outputText = if (ModelHolder.decoderSession != null && hiddenStates is Array<*>) { - runDecoderLoop(hiddenStates, attentionMask, maxTokens) - } else { - Log.w(TAG, "Decoder not available, returning original text") - text + // Also strip common prefixes that the model might generate or echo + val prefixesToStrip = listOf( + "Output:", "Corrected:", "Translation:", "Response:", "Result:", + "Output: ", "Corrected: ", "Translation: ", "Response: ", "Result: " + ) + for (prefix in prefixesToStrip) { + if (cleanedOutput.startsWith(prefix, ignoreCase = true)) { + cleanedOutput = cleanedOutput.substring(prefix.length).trim() + break + } } - // Clean up - inputTensor.close() - attentionTensor.close() - encoderResults.close() + // If the model wrapped the output in quotes, strip them + if (cleanedOutput.startsWith("\"") && cleanedOutput.endsWith("\"")) { + cleanedOutput = cleanedOutput.substring(1, cleanedOutput.length - 1).trim() + } + if (cleanedOutput.startsWith("'") && cleanedOutput.endsWith("'")) { + cleanedOutput = cleanedOutput.substring(1, cleanedOutput.length - 1).trim() + } - // Schedule unload after work is done - ModelHolder.scheduleUnload(context) - - // Strip prompt prefix if model echoed it back - val cleanedOutput = if (prompt.isNotBlank() && outputText.startsWith(prompt, ignoreCase = true)) { - outputText.removePrefix(prompt).trimStart() + // Post-process to strip thinking/reasoning tags if showThinkingVal is false + val finalOutput = if (!showThinkingVal) { + stripThinkingTags(cleanedOutput) } else { - outputText + cleanedOutput } - - if (cleanedOutput.isNotBlank()) { - Result.success(cleanedOutput) + + Log.i(TAG, "proofread: input='$text' prompt='$fullPrompt' generated='$output' final='$finalOutput'") + if (finalOutput.isNotBlank()) { + Result.success(finalOutput) } else { Result.success(text) } } catch (e: Throwable) { + if (e is kotlinx.coroutines.CancellationException) { + // Cancel completion job if running + try { + val helper = ModelHolder.llamaHelper + if (helper != null) { + val completionJobField = LlamaHelper::class.java.getDeclaredField("completionJob").apply { isAccessible = true } + val completionJob = completionJobField.get(helper) as? Job + completionJob?.cancel() + } + } catch (ex: Throwable) { + Log.w(TAG, "Failed to cancel completion job", ex) + } + throw e + } Log.e(TAG, "Proofread failed", e) ModelHolder.scheduleUnload(context) // Ensure we still schedule unload on error Result.failure(ProofreadException(e.message ?: "Unknown error")) } } - /** - * Run decoder auto-regressively to generate output tokens. - * Supports multiple T5 decoder variants: - * - Basic decoder (input_ids, encoder_hidden_states, encoder_attention_mask) - * - Decoder with past (adds past_key_values/pkv_* inputs) - * - Merged decoder (adds use_cache_branch flag) - */ - private fun runDecoderLoop(encoderHiddenStates: Array<*>, encoderAttentionMask: LongArray, maxTokens: Int): String { - if (ModelHolder.decoderSession == null) return "" - + private fun predictWithParams( + helper: LlamaHelper, + prompt: String, + temp: Float, + topP: Float, + topK: Int, + minP: Float, + maxTokens: Int, + showThinking: Boolean + ) { try { - // Get hidden states as 3D array [batch, seq, hidden] - @Suppress("UNCHECKED_CAST") - val hiddenArray = encoderHiddenStates[0] as? Array ?: return "" - val seqLen = hiddenArray.size - val hiddenDim = hiddenArray[0].size - - - - // Flatten hidden states for tensor - val flatHidden = FloatArray(seqLen * hiddenDim) - for (i in 0 until seqLen) { - System.arraycopy(hiddenArray[i], 0, flatHidden, i * hiddenDim, hiddenDim) - } - - // Create encoder_hidden_states tensor - val hiddenShape = longArrayOf(1, seqLen.toLong(), hiddenDim.toLong()) - val hiddenTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, FloatBuffer.wrap(flatHidden), hiddenShape) - - // Create encoder attention mask tensor - val attentionShape = longArrayOf(1, encoderAttentionMask.size.toLong()) - val attentionTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, LongBuffer.wrap(encoderAttentionMask), attentionShape) - - // Analyze decoder inputs to determine model type - val inputNames = ModelHolder.decoderSession!!.inputNames.toList() - val pkvInputNames = inputNames.filter { it.startsWith("past_key_values") || it.startsWith("pkv") } - val useCacheBranchInput = inputNames.find { it == "use_cache_branch" } - val numLayers = pkvInputNames.size / 4 // 4 tensors per layer (decoder key/value, encoder key/value) - - val hasPkvInputs = pkvInputNames.isNotEmpty() - val isMergedDecoder = useCacheBranchInput != null - - - - // Start with decoder start token (pad_token = 0 for T5) - val generatedTokens = mutableListOf(0L) - val eosTokenId = ModelHolder.tokenizer!!.getEosTokenId() - - // KV-cache storage for decoders that output present.* tensors - var pastKeyValues: Map? = null - - val startTime = System.currentTimeMillis() - - for (step in 0 until maxTokens) { - // For KV-cache models, only pass the last token after first step - var isValidPkv = false - if (hasPkvInputs && pastKeyValues != null) { - val currentPkv = pastKeyValues!!.values.firstOrNull() - if (currentPkv != null) { - val sequenceLength = currentPkv.info.shape[2] - if (sequenceLength > 0) { - isValidPkv = true - } - } - } - - // CRITICAL FIX: Only use valid pastKeyValues if model actually accepts PKV inputs - val inputTokens = if (step > 0 && isValidPkv && hasPkvInputs) { - longArrayOf(generatedTokens.last()) - } else { - generatedTokens.toLongArray() - } - - val decoderShape = longArrayOf(1, inputTokens.size.toLong()) - val decoderInputTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, LongBuffer.wrap(inputTokens), decoderShape) - - // Build decoder inputs - val decoderInputs = mutableMapOf() - for (inputName in inputNames) { - when { - inputName.contains("input_ids") || inputName.contains("decoder_input_ids") -> - decoderInputs[inputName] = decoderInputTensor - inputName.contains("encoder_hidden_states") || inputName.contains("hidden_states") -> - decoderInputs[inputName] = hiddenTensor - inputName.contains("encoder_attention_mask") || inputName.contains("attention_mask") -> - decoderInputs[inputName] = attentionTensor - } - } - - // Handle use_cache_branch for merged decoders - if (isMergedDecoder && useCacheBranchInput != null) { - val useCacheValue = step > 0 // false on first run, true after - val useCacheTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, booleanArrayOf(useCacheValue)) - decoderInputs[useCacheBranchInput] = useCacheTensor - } - - // Add past_key_values from previous step (if available and model expects them) - if (isValidPkv) { - for ((name, tensor) in pastKeyValues!!) { - // Map present.X.* output names to past_key_values.X.* or pkv_* input names - val inputName = name.replace("present", "past_key_values") - if (inputNames.contains(inputName)) { - decoderInputs[inputName] = tensor - } else { - // Try pkv format (pkv_0, pkv_1, etc.) - val pkvMatch = pkvInputNames.find { it.endsWith(name.substringAfter("present.")) } - if (pkvMatch != null) { - decoderInputs[pkvMatch] = tensor - } - } - } - } else if (hasPkvInputs) { - // First step with PKV model or invalid cache: provide zero tensors - // T5 pkv format: pkv_0 to pkv_N where first half is decoder self-attn, second half is encoder cross-attn - // Shape: [batch, num_heads, seq_len, head_dim] - - var numHeads = 8L // Default T5-small - - // improved head detection from model metadata - try { - // Try to find the shape of the first PKV input - val pkvInfo = ModelHolder.decoderSession!!.inputInfo[pkvInputNames.first()] - val shape = pkvInfo?.info as? ai.onnxruntime.TensorInfo - if (shape != null) { - val dims = shape.shape - // Shape is usually [batch, heads, seq, dim] -> index 1 - if (dims.size == 4 && dims[1] > 0) { - numHeads = dims[1] - } - } - } catch (e: Exception) { - Log.w(TAG, "Could not detect numHeads from model info", e) - } - - val headDim = hiddenDim.toLong() / numHeads - val numPkv = pkvInputNames.size - - for (pkvName in pkvInputNames) { - // Determine if this is encoder cross-attention or decoder self-attention - // pkv_0 to pkv_(N/2-1) = decoder self-attention (seq_len = 0 initially) - // pkv_(N/2) to pkv_(N-1) = encoder cross-attention (seq_len = encoder_seq_len) - val pkvIndex = pkvName.removePrefix("pkv_").removePrefix("past_key_values.").toIntOrNull() ?: 0 - val isEncoderPkv = pkvIndex >= numPkv / 2 || pkvName.contains("encoder") - - val pkvSeqLen = if (isEncoderPkv) seqLen.toLong() else 0L - val pkvShape = longArrayOf(1, numHeads, pkvSeqLen, headDim) - val emptyPkv = FloatArray((1 * numHeads * pkvSeqLen * headDim).toInt()) - val pkvTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, FloatBuffer.wrap(emptyPkv), pkvShape) - decoderInputs[pkvName] = pkvTensor - } - } - + // Get currentContext via reflection + val currentContextField = LlamaHelper::class.java.getDeclaredField("currentContext").apply { isAccessible = true } + val currentContext = currentContextField.get(helper) as? Int ?: throw IllegalStateException("Model not loaded yet") + + // Get llama via reflection + val llamaField = LlamaHelper::class.java.getDeclaredField("llama\$delegate").apply { isAccessible = true } + val llamaLazy = llamaField.get(helper) as Lazy + val llama = llamaLazy.value + + // Reset tokenCount and allText + val tokenCountField = LlamaHelper::class.java.getDeclaredField("tokenCount").apply { isAccessible = true } + tokenCountField.set(helper, 0) + + val allTextField = LlamaHelper::class.java.getDeclaredField("allText").apply { isAccessible = true } + allTextField.set(helper, "") + + // Emit Started event + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Started(prompt)) + + // Build parameters map + val params = mutableMapOf( + "prompt" to prompt, + "emit_partial_completion" to true, + "temperature" to temp.toDouble(), + "top_p" to topP.toDouble(), + "top_k" to topK, + "min_p" to minP.toDouble(), + "n_predict" to maxTokens, + "stop" to listOf("\nInput:", "\nInstruction:", "\nOutput:", "\nCorrected:") + ) - - // Run decoder step - val decoderResults = ModelHolder.decoderSession!!.run(decoderInputs) - - // Get logits (usually first output) - var logitsOutput: Any? = null - val newPastKeyValues = mutableMapOf() - - for (i in 0 until decoderResults.size()) { - val outputInfo = ModelHolder.decoderSession!!.outputNames.toList()[i] - val outputValue = decoderResults[i] - - when { - outputInfo == "logits" || i == 0 -> { - logitsOutput = outputValue.value - } - outputInfo.startsWith("present") -> { - // Save present.* outputs for next step - // Need to copy tensor data since result will be closed - val tensorValue = outputValue.value - if (tensorValue is Array<*>) { - @Suppress("UNCHECKED_CAST") - val floatData = tensorValue as? Array>> - if (floatData != null) { - val batch = floatData.size - val heads = floatData[0].size - val seqL = floatData[0][0].size - val dim = floatData[0][0][0].size - val flat = FloatArray(batch * heads * seqL * dim) - var idx = 0 - for (b in 0 until batch) { - for (h in 0 until heads) { - for (s in 0 until seqL) { - System.arraycopy(floatData[b][h][s], 0, flat, idx, dim) - idx += dim - } - } - } - val shape = longArrayOf(batch.toLong(), heads.toLong(), seqL.toLong(), dim.toLong()) - newPastKeyValues[outputInfo] = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, FloatBuffer.wrap(flat), shape) - } - } - } - } - } - - val nextToken = getNextToken(logitsOutput, inputTokens.size.toLong() - 1) - - // Close previous PKV tensors and update with new ones - pastKeyValues?.values?.forEach { it.close() } - pastKeyValues = if (newPastKeyValues.isNotEmpty()) newPastKeyValues else null - - decoderInputTensor.close() - decoderResults.close() - - // Check for EOS - if (nextToken == eosTokenId) { - break + // Get completionJob field + val completionJobField = LlamaHelper::class.java.getDeclaredField("completionJob").apply { isAccessible = true } + + // Launch completion using helper.scope + val job = helper.scope.launch { + val startTime = System.currentTimeMillis() + try { + llama.launchCompletion(currentContext, params) + } catch (e: Throwable) { + Log.e(TAG, "Completion failed", e) + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Error("Completion failed: ${e.message}")) + return@launch } - - generatedTokens.add(nextToken) + val duration = System.currentTimeMillis() - startTime + val allText = allTextField.get(helper) as String + val tokenCount = tokenCountField.get(helper) as Int + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Done(allText, tokenCount, duration)) } - - val decoderTime = System.currentTimeMillis() - startTime - - // Clean up - pastKeyValues?.values?.forEach { it.close() } - hiddenTensor.close() - attentionTensor.close() - - // Decode tokens (skip first token which is start token) - val outputTokens = generatedTokens.drop(1).toLongArray() - return ModelHolder.tokenizer!!.decode(outputTokens) - - } catch (e: Exception) { - Log.e(TAG, "Decoder loop failed", e) - return "" + completionJobField.set(helper, job) + } catch (e: Throwable) { + Log.e(TAG, "Failed to setup prediction", e) + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Error("Failed to setup prediction: ${e.message}")) } } - /** - * Get next token from logits using greedy decoding. - */ - private fun getNextToken(logits: Any?, position: Long): Long { - val pos = position.toInt() - return when (logits) { - is Array<*> -> { - // Shape: [batch, seq, vocab] - @Suppress("UNCHECKED_CAST") - val batchLogits = logits[0] as? Array - if (batchLogits != null && pos < batchLogits.size) { - val vocabLogits = batchLogits[pos] - // Argmax - vocabLogits.indices.maxByOrNull { vocabLogits[it] }?.toLong() ?: 0L - } else 0L - } - is FloatArray -> { - // Direct vocab logits - logits.indices.maxByOrNull { logits[it] }?.toLong() ?: 0L - } - else -> { - Log.w(TAG, "Unknown logits type: ${logits?.javaClass}") - 0L - } - } + private fun stripThinkingTags(text: String): String { + return text + .replace(Regex("[\\s\\S]*?", RegexOption.IGNORE_CASE), "") + .replace(Regex("[\\s\\S]*?", RegexOption.IGNORE_CASE), "") + .replace(Regex("[\\s\\S]*?", RegexOption.IGNORE_CASE), "") + .replace(Regex("

[\\s\\S]*?
", RegexOption.IGNORE_CASE), "") + .trim() } - + private fun getTranslationFewShot(targetLanguage: String): List> { + val lang = targetLanguage.trim().lowercase() + return when { + lang.contains("french") || lang.contains("français") -> listOf( + "Hello, how are you?" to "Bonjour, comment allez-vous?", + "My name is Alex." to "Je m'appelle Alex." + ) + lang.contains("spanish") || lang.contains("español") -> listOf( + "Hello, how are you?" to "Hola, ¿cómo estás?", + "My name is Alex." to "Mi nombre es Alex." + ) + lang.contains("german") || lang.contains("deutsch") -> listOf( + "Hello, how are you?" to "Hallo, wie geht es dir?", + "My name is Alex." to "Mein Name ist Alex." + ) + lang.contains("italian") || lang.contains("italiano") -> listOf( + "Hello, how are you?" to "Ciao, come stai?", + "My name is Alex." to "Il mio nome è Alex." + ) + lang.contains("portuguese") || lang.contains("português") -> listOf( + "Hello, how are you?" to "Olá, como você está?", + "My name is Alex." to "Meu nome é Alex." + ) + lang.contains("dutch") || lang.contains("nederlands") -> listOf( + "Hello, how are you?" to "Hallo, hoe gaat het met je?", + "My name is Alex." to "Mijn naam is Alex." + ) + lang.contains("russian") || lang.contains("русский") -> listOf( + "Hello, how are you?" to "Привет, как дела?", + "My name is Alex." to "Меня зовут Алекс." + ) + lang.contains("chinese") || lang.contains("中文") || lang.contains("汉语") -> listOf( + "Hello, how are you?" to "你好,你好吗?", + "My name is Alex." to "我的名字是亚历克斯。" + ) + lang.contains("japanese") || lang.contains("日本語") -> listOf( + "Hello, how are you?" to "こんにちは、お元気ですか?", + "My name is Alex." to "私の名前はアレックスです。" + ) + lang.contains("hindi") || lang.contains("हिन्दी") -> listOf( + "Hello, how are you?" to "नमस्ते, आप कैसे हैं?", + "My name is Alex." to "मेरा नाम एलेक्स है।" + ) + else -> emptyList() + } + } class ProofreadException(message: String) : Exception(message) class TranslateException(message: String) : Exception(message) companion object { - private const val TAG = "OnnxProofreadService" - private const val KEY_ENCODER_PATH = "offline_model_path" + private const val TAG = "LlamaProofreadService" + private const val KEY_MODEL_PATH = "offline_model_path" private const val KEY_DECODER_PATH = "offline_decoder_path" private const val KEY_TOKENIZER_PATH = "offline_tokenizer_path" - val AVAILABLE_MODELS = listOf("T5 Grammar Correction (ONNX)") + val AVAILABLE_MODELS = listOf("GGUF Model (Local)") } } diff --git a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt index ec537f622..3e7537c8c 100644 --- a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt +++ b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt @@ -23,6 +23,11 @@ object ProofreadHelper { var lastOriginalText: String? = null private set + @JvmStatic + fun preloadModel(context: Context) { + // No-op for offlinelite flavor (no AI support) + } + @JvmStatic fun cancelCurrentOperation() { /* No-op */ } diff --git a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt index 30cfa743f..49cb8584c 100644 --- a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -20,7 +20,7 @@ class ProofreadService(private val context: Context) { val prefs: SharedPreferences get() = context.prefs() // Always returns GEMINI as default, but methods do nothing - fun getProvider(): AIProvider = AIProvider.GEMINI +fun getProvider(): AIProvider = AIProvider.GEMINI fun setProvider(provider: AIProvider) { /* No-op */ } suspend fun fetchAvailableModels(provider: AIProvider): List = emptyList() @@ -34,6 +34,8 @@ class ProofreadService(private val context: Context) { fun unloadModel() { /* No-op */ } fun getSystemPrompt(): String = "" fun setSystemPrompt(prompt: String) { /* No-op */ } + fun getTranslateSystemPrompt(): String = "" + fun setTranslateSystemPrompt(prompt: String) { /* No-op */ } fun getDecoderPath(): String? = null fun setDecoderPath(path: String?) { /* No-op */ } fun getTokenizerPath(): String? = null diff --git a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadHelper.kt b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadHelper.kt index ee26866f6..08e7b78b5 100644 --- a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadHelper.kt +++ b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadHelper.kt @@ -36,6 +36,14 @@ object ProofreadHelper { var lastOriginalText: String? = null private set + /** + * Preload the model in the background to avoid initial latency. + */ + @JvmStatic + fun preloadModel(context: Context) { + // No-op for standard flavor (runs API based proofreader) + } + /** * Cancel the current proofreading/translation operation if one is in progress. */ diff --git a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt index acb0b29a1..5230a545f 100644 --- a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -62,14 +62,14 @@ class ProofreadService(private val context: Context) { val prefs: SharedPreferences get() = context.prefs() // Provider selection - fun getProvider(): AIProvider { - val providerStr = context.prefs().getString(KEY_PROVIDER, AIProvider.GEMINI.name) - return try { - AIProvider.valueOf(providerStr ?: AIProvider.GEMINI.name) - } catch (e: IllegalArgumentException) { - AIProvider.GEMINI - } +fun getProvider(): AIProvider { + val providerStr = context.prefs().getString(KEY_PROVIDER, AIProvider.GEMINI.name) + return try { + AIProvider.valueOf(providerStr ?: AIProvider.GEMINI.name) + } catch (e: IllegalArgumentException) { + AIProvider.GEMINI } +} fun setProvider(provider: AIProvider) { context.prefs().edit().putString(KEY_PROVIDER, provider.name).apply() @@ -171,6 +171,8 @@ class ProofreadService(private val context: Context) { fun unloadModel() { /* No-op */ } fun getSystemPrompt(): String = "Fix grammar and spelling" fun setSystemPrompt(prompt: String) { /* No-op */ } + fun getTranslateSystemPrompt(): String = "" + fun setTranslateSystemPrompt(prompt: String) { /* No-op */ } fun getDecoderPath(): String? = null fun setDecoderPath(path: String?) { /* No-op */ } fun getTokenizerPath(): String? = null @@ -238,6 +240,13 @@ class ProofreadService(private val context: Context) { securePrefs.edit().putString(KEY_HF_ENDPOINT, endpoint.trim()).apply() } + fun isAllowInsecureConnections(): Boolean = + context.prefs().getBoolean( + helium314.keyboard.latin.settings.Settings.PREF_AI_ALLOW_INSECURE_CONNECTIONS, + helium314.keyboard.latin.settings.Defaults.PREF_AI_ALLOW_INSECURE_CONNECTIONS + ) + + /** * Tests the API key by making a simple request. * @return Result with success message or error @@ -463,9 +472,22 @@ class ProofreadService(private val context: Context) { ) } - val url = URL(getHuggingFaceEndpoint()) + val endpoint = getHuggingFaceEndpoint() + val isHttp = endpoint.startsWith("http://", ignoreCase = true) + val allowInsecure = isAllowInsecureConnections() + + if (isHttp && !allowInsecure) { + return Result.failure( + ProofreadException(context.getString(R.string.insecure_connection_blocked)) + ) + } + + val url = URL(endpoint) val connection = url.openConnection() as HttpURLConnection - + if (allowInsecure && connection is javax.net.ssl.HttpsURLConnection) { + bypassSSLVerification(connection) + } + return try { connection.requestMethod = "POST" connection.setRequestProperty("Content-Type", "application/json") @@ -511,6 +533,22 @@ class ProofreadService(private val context: Context) { } } + private fun bypassSSLVerification(connection: javax.net.ssl.HttpsURLConnection) { + try { + val trustAllCerts = arrayOf(object : javax.net.ssl.X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + }) + val sslContext = javax.net.ssl.SSLContext.getInstance("SSL") + sslContext.init(null, trustAllCerts, java.security.SecureRandom()) + connection.sslSocketFactory = sslContext.socketFactory + connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true } + } catch (e: Exception) { + Log.e("ProofreadService", "Failed to bypass SSL verification", e) + } + } + private fun parseOpenAIResponse(response: String, showThinking: Boolean): Result { return try { // OpenAI-compatible format: {"choices": [{"message": {"content": "..."}}]} @@ -520,6 +558,39 @@ class ProofreadService(private val context: Context) { val firstChoice = choices.getJSONObject(0) val message = firstChoice.optJSONObject("message") var content = message?.optString("content", "") ?: "" + + if (message != null) { + val contentArray = message.optJSONArray("content") + if (contentArray != null) { + val parts = mutableListOf() + for (i in 0 until contentArray.length()) { + when (val part = contentArray.opt(i)) { + is String -> if (part.isNotBlank()) parts.add(part) + is JSONObject -> { + val type = part.optString("type", "") + val text = part.optString("text", "").ifBlank { + part.optString("content", "") + } + if (text.isNotBlank() && (showThinking || type != "reasoning")) { + parts.add(text) + } + } + } + } + content = parts.joinToString("\n") + } + + content = content.trim() + if (content.isBlank() && showThinking) { + content = message.optString("reasoning_content", "").trim().ifBlank { + message.optString("reasoning", "").trim() + } + } + } + + if (content.isBlank()) { + content = firstChoice.optString("text", "").trim() + } if (!showThinking && content.isNotBlank()) { // Filter out ... blocks diff --git a/app/src/test/java/helium314/keyboard/latin/handwriting/HandwritingLoaderTest.kt b/app/src/test/java/helium314/keyboard/latin/handwriting/HandwritingLoaderTest.kt new file mode 100644 index 000000000..f6fd34416 --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/handwriting/HandwritingLoaderTest.kt @@ -0,0 +1,47 @@ +package helium314.keyboard.latin.handwriting + +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +@RunWith(RobolectricTestRunner::class) +class HandwritingLoaderTest { + + private lateinit var context: Context + private lateinit var testApk: File + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + testApk = File(context.cacheDir, "test_plugin.apk") + testApk.writeText("dummy content for handwriting plugin") + } + + @After + fun tearDown() { + testApk.delete() + HandwritingLoader.removePlugin(context) + } + + @Test + fun testImportCleanupOnInvalidApk() { + // Initially no plugin + assertFalse(HandwritingLoader.hasPlugin(context)) + + // Import invalid apk + val uri = Uri.fromFile(testApk) + val result = HandwritingLoader.importPlugin(context, uri) + assertFalse(result) // Must fail because dummy text is not a valid DEX/APK with the class + + // Verify the file was cleaned up on failure + val apkFile = File(context.filesDir, "handwriting_plugin.apk") + assertFalse(apkFile.exists()) + } +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 796e20fdc..0db34dadc 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -16,6 +16,7 @@ LeanType integrates with AI providers to offer advanced proofreading and transla | 👐 **[Two-thumb Typing (experimental)](#two-thumb-typing-experimental)** | Mix taps and swipes naturally; manual spacing; Nintype-style. | | 📝 **[Text Expander](#6-text-expander)** | Custom text shortcut expansion. | | 🖱️ **[Touchpad Mode](#7-touchpad-mode)** | Full-screen touchpad gestures and controls. | +| ✍️ **[Handwriting Input](#8-handwriting-input)** | Use handwriting recognition to draw letters directly on a canvas. | ## Summary of New Features @@ -23,6 +24,7 @@ LeanType integrates with AI providers to offer advanced proofreading and transla | :--- | :--- | :--- | | **Multi-Provider AI** | Uses Gemini, Groq, or OpenAI to proofread/rewrite text. Fetch latest models dynamically. | `AI Integration > Set AI Provider` | | **Offline Proofreading** | Private, on-device AI for grammar (requires downloads). | `AI Integration > Offline Proofreading` | +| **GGUF Model Support** | Load and run highly quantized, compact GGUF models on-device for offline proofreading/translation. | `Advanced > GGUF Model (.gguf)` | | **Custom AI Keys** | 10 toolbar keys with custom prompts, tags (themed capsules), and toggle settings (supports hashtags). | `AI Integration > Custom Keys` | | **AI Translation** | Translates selected text via your configured AI provider (includes separate model selector). | Toolbar > Translate Icon | | **Floating Keyboard** | Detach the keyboard into a draggable window with a persistent mode option. | Toolbar > Floating Keyboard | @@ -39,6 +41,7 @@ LeanType integrates with AI providers to offer advanced proofreading and transla | **Clipboard Undo** | Undo swipe-to-delete on clipboard items with a timed undo bar. | *Automatic (on swipe delete)* | | **Two-thumb Typing** | Mix taps and swipes naturally, multi-tap then swipe, manual spacing, recognition tweaks. All experimental and opt-in. | `Two-thumb typing (experimental)` | | **Text Expander** | Expand custom shortcuts using dynamic template variables (date, time, clipboard, custom placeholders). | `Text correction > Text Expander` | +| **Handwriting Input** | Draw letters or words directly on the screen keyboard space to type (standard variant, requires plugin). | `Libraries > Handwriting Input Plugin` | --- @@ -90,7 +93,7 @@ LeanType integrates with AI providers to offer advanced proofreading and transla | **Groq** | 🟡 Average | 🟢 Easy | High | **Speed** | | **Google Gemini** | 🔴 Low | 🟢 Easy | Generous | General Purpose | | **HF/OpenAI-compatible** | ⚙️ *Varies* | 🟡 Medium | *Varies* | **Fully Customizable** | -| **Offline (ONNX)** | 🟢 **Best** | 🟡 Medium | ∞ Unlimited | **Privacy** | +| **Offline (Llama)** | 🟢 **Best** | 🟡 Medium | ∞ Unlimited | **Privacy** | > [!TIP] > The **HF/OpenAI-compatible** option is fully customizable—you can change the API endpoint, token, and model to use *any* OpenAI-compatible service (OpenRouter, Mistral, DeepSeek, HuggingFace, etc.). @@ -316,30 +319,30 @@ Control how the result is inserted. **Note**: This feature is only available in the "Offline" build flavor of LeanType. -Offline proofreading runs entirely on your device using the ONNX Runtime engine. No data leaves your device. +Offline proofreading runs entirely on your device using the `llama.cpp` runtime. No data leaves your device. > [!NOTE] > **Status: Beta / Experimental** -> This feature is in a test phase. The engine is designed to be compatible with various T5-based ONNX models (Basic, Quantized, KV-Cache). We encourage you to experiment with different models to find the best balance of speed and accuracy for your device. +> Running large language models on device requires a modern smartphone with sufficient RAM (typically 6GB+). We recommend using highly quantized, compact GGUF models (e.g. Q4_K_M or IQ4_NL) for the best balance of speed, accuracy, and memory usage. The overall accuracy of proofreading and translations will depend entirely on the capabilities of the specific model you choose. ### Setup Instructions -1. **Download Model Files**: Download the **Encoder**, **Decoder**, and **Tokenizer** for your chosen model from the table below. +1. **Download a GGUF Model**: Download a compatible `.gguf` model file (see Recommended Models below). 2. **Configure App**: * Go to **Settings > Advanced**. - * **Encoder Model**: Select the downloaded `.onnx` encoder file. - * **Decoder Model**: Select the downloaded `.onnx` decoder file. - * **Tokenizer**: Select the `tokenizer.json` file. - * **System Instruction**: Enter the text specified in the "System Instruction" column for your model (leave empty if specified). + * **GGUF Model**: Select the downloaded `.gguf` model file. + * **System Instruction**: (Optional) Customize the prompt used to guide the model when proofreading text. + * **Translate Instruction**: (Optional) Customize the prompt used for translation. + * **Target Language**: Select the target language for offline translation. + * **Sampling Settings**: Adjust temperature, Top-K, and Top-P to control model creativity. ### Recommended Models -| Model & Purpose | Performance / Size | System Instruction | Download Links (Direct) | -| :--- | :--- | :--- | :--- | -| **Visheratin T5 Tiny**
*(Grammar Correction Only)* | ⚡ **Fastest**
~35 MB
Low RAM usage | **Empty**
(Leave blank) | • [Encoder](https://huggingface.co/visheratin/t5-efficient-tiny-grammar-correction/resolve/main/encoder_model_quant.onnx)
• [Decoder](https://huggingface.co/visheratin/t5-efficient-tiny-grammar-correction/resolve/main/init_decoder_quant.onnx)
• [Tokenizer](https://huggingface.co/visheratin/t5-efficient-tiny-grammar-correction/tree/main) | -| **Flan-T5 Small**
*(Translation & General)* | 🐢 **Slower**
~300 MB
Higher accuracy | **Required**
`fix grammar: `
or
`translate English to Spanish: ` | • [Encoder](https://huggingface.co/Xenova/flan-t5-small/resolve/main/onnx/encoder_model_quantized.onnx)
• [Decoder](https://huggingface.co/Xenova/flan-t5-small/resolve/main/onnx/decoder_model_quantized.onnx)
• [Tokenizer](https://huggingface.co/Xenova/flan-t5-small/tree/main) | +* **Llama 3.2 1B Instruct (Q4_K_M)**: Excellent general purpose compact model (~900 MB). +* **Qwen 2.5 1.5B Instruct (Q4_K_M)**: High accuracy and quality, fast on modern devices (~1.1 GB). +* **Qwen 2.5 0.5B Instruct (Q4_K_M)**: Extremely lightweight, very fast with minimal memory footprint (~350 MB). -*Note: For Flan-T5, the quantized models linked above are standard recommendations. Users have also reported success with `bnb4` quantized variants if available.* +You can find and download these models in GGUF format on HuggingFace (e.g., from users like `bartowski` or `Qwen`). --- @@ -377,8 +380,46 @@ Touchpad Mode replaces the keyboard with a laptop-style touchpad overlay to cont * **Toolbar shortcut**: Tap the **Touchpad** icon in the toolbar for a persistent touchpad overlay. ### Touchpad Gestures -* **Single-finger drag**: Moves the cursor in 2D space (simulating arrow keys left/right/up/down) to navigate text. -* **Two-finger drag**: Performs fast vertical scrolling (simulating arrow keys up/down). -* **Two-finger tap**: Simulates a mouse click/Enter. -* **Long press (hold finger)**: Activates text selection mode. Dragging while holding will select text. Releasing the finger exits selection mode. -* **Double tap by single finger**: Deletes the selected text or words (if a text selection exists). + +#### 1 Finger (Navigation & Selection) +* **Drag**: Moves the cursor precisely character-by-character. +* **Double Tap**: Selects the word under the cursor. +* **Long Press & Drag**: Enters text selection mode and selects text as you drag. + +#### 2 Fingers (Navigation, Clipboard, History & Deletion) +* **Drag Left/Right**: Moves the cursor horizontally word-by-word. +* **Swipe Up**: Undo. +* **Swipe Down**: Redo. +* **Tap**: Inserts a space character. +* **Double Tap**: Copies selected text (or Pastes clipboard contents if no selection exists). +* **Triple Tap**: Cuts selected text (or Selects All if no selection exists). +* **Press & Hold (Long Press)**: Deletes (backspaces) selection / word to the left. Repeats automatically if held. + +--- + +## 8. Handwriting Input + +> [!NOTE] +> **Availability**: This feature is only available in the **Standard** (`-standard-release.apk`) and **Standard Optimised** build flavors. It is excluded from the **Offline** and **Offline Lite** variants. + +LeanType integrates a handwriting recognition canvas that allows you to write characters directly on the keyboard using your finger or a stylus. + +### Setup Instructions + +1. **Install the Plugin**: + * Go to **Settings > Libraries**. + * Under **Handwriting Input Plugin**, tap **Download** to pull the latest plugin APK from the [Leantype-Handwriting-Plugin](https://github.com/LeanBitLab/Leantype-Handwriting-Plugin) GitHub repository. + * Alternatively, you can tap to load a locally downloaded plugin APK file. + * Review the security warning and confirm the installation. The app will verify and register the plugin. + +2. **Accessing the Handwriting Key**: + * The **Handwriting** icon (represented by a pencil/edit icon) is placed on your keyboard toolbar by default in supported variants. + * If it is not showing, you can customize the toolbar under **Settings > Preferences > Keyboard toolbar** to enable it. + +### How to Use + +1. Tap the **Handwriting** icon in the toolbar. +2. The keyboard area will switch to a handwriting drawing canvas. +3. Draw characters, words, or punctuation symbols on the canvas. The keyboard will automatically inputs recognized characters. +4. Tap the **Clear (X)** button on the bottom row to clear the current drawing canvas. +5. Tap the **Handwriting** icon again to toggle back to the standard keyboard layout. diff --git a/docs/badges/download.svg b/docs/badges/download.svg new file mode 100644 index 000000000..ee156f654 --- /dev/null +++ b/docs/badges/download.svg @@ -0,0 +1 @@ +VersionVersionv3.8.5v3.8.5 diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg new file mode 100644 index 000000000..df1200edc --- /dev/null +++ b/docs/badges/downloads.svg @@ -0,0 +1 @@ +DownloadsDownloads3069330693 diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg new file mode 100644 index 000000000..66a371b67 --- /dev/null +++ b/docs/badges/stars.svg @@ -0,0 +1 @@ +StarsStars488488 diff --git a/fastlane/metadata/android/en-US/changelogs/4000.txt b/fastlane/metadata/android/en-US/changelogs/4000.txt new file mode 100644 index 000000000..910f2974a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/4000.txt @@ -0,0 +1,5 @@ +Handwriting input (Standard) — write on a recognition canvas using a downloadable plugin. +Offline AI now runs on-device GGUF models via llama.cpp, with adjustable sampling. +Richer touchpad gestures: word select, word-by-word navigation, clipboard, undo/redo, hold-to-backspace. +Auto-read one-time codes from an incoming SMS as a tappable suggestion. +Regex shortcuts in Text Expander. From 985446b0d047b62362876a61d68b34f3b99275f4 Mon Sep 17 00:00:00 2001 From: AsafMah Date: Thu, 25 Jun 2026 11:21:56 +0300 Subject: [PATCH 6/6] Merge upstream LeanType v3.8.8 before 3.10.0 release (#111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(settings): fix and optimize gboard import Parse Gboard format header dynamically to fix missing words and swapped values. Optimize using bulkInsert to reduce database insertion IPC overhead. * chore: bump version to 3.8.4 Set versionCode to 3840 and versionName to 3.8.4 in build.gradle.kts. Create Fastlane changelog metadata at 3840.txt. * feat(settings): improve text expander ui/ux Add responsive search filtering for text expansion shortcuts. Add quick placeholder selection row with rich cursor-based insert support to Add/Edit Dialog. * feat(settings): polish text expander guide and list Redesign Quick Feature Guide card with styled step badges. Redesign custom shortcuts list items with premium keyword badges and chevrons. Redesign empty state with card illustration layout. * feat(settings): clean up text expander guide Remove redundant template placeholder explanation while retaining the list of supported placeholder tags. * feat(settings): add more expander placeholders Add support and UI representations for %month%, %month_short%, %year%, and %week% placeholders. * feat(settings): add system template placeholders Add and integrate support for %battery%, %device%, and %android% placeholders. * feat(settings): add language placeholder Add and integrate support for %language% placeholder, which expands to the current active keyboard display language. * fix(perf): prevent OOM on background image decode - Add BitmapUtils.decodeSampledBitmap() with two-pass decode and inSampleSize - Use RGB_565 config for non-PNG images to halve memory usage - Use BitmapFactory.decodeStream (with InputStream) instead of decodeFile - Cap background bitmap at 2048px max dimension - Recycle temp bitmap after validation in setBackgroundImage Fixes: Settings.java:527, BackgroundImagePreference.kt:122 * fix(stability): replace force-unwrap !! in hot paths - Colors.kt: use 'let' smart cast and 'error' instead of NPE on missing keyBackground - FloatingKeyboardManager.kt: safe-call on overlayRoot, early return on null - SuggestionStripView.kt: early return true on missing drawable Prevents IME process crashes from null drawables/bitmaps in keyboard rendering paths. * fix(stability): unregister SharedPreferences listener in spell-checker AndroidSpellCheckerService.onDestroy() now unregisters the OnSharedPreferenceChangeListener. Without this, the SharedPreferences implementation kept a strong reference to the service, leaking it through every spell-check session the system bound/unbound. * fix(stability): don't call Looper.prepare() on background thread BackupRestorePreference.kt was calling Looper.prepare() from the ScheduledThreadPool executor, leaking a Looper per restore and posting UI work onto an unreliable thread. Use Handler(Looper.getMainLooper()) to dispatch the FeedbackManager.message call to the UI thread. * fix(stability): make score-limit cache update atomic in Suggest The previous implementation had a non-atomic read-then-write of mLastScoreLimitUpdateTime and mCachedScoreLimitForAutocorrect across threads (suggestion lookup can happen on background threads via SuggestionSpan / TextClassifier). Two threads could both miss the interval check and recompute, with the second write overwriting the first. Wrap the cache update in synchronized(this) to make the check and update atomic. * fix(stability): use named lock for dictionary blacklist Three blacklist operations in DictionaryGroup used `.apply { scope.launch { synchronized(this) { ... } } }`, which re-bound `this` to the HashSet / CoroutineScope inside the synchronized block. Two threads could enter the critical section concurrently because they were locking on different objects. Add an explicit `blacklistLock: Any` and synchronize on it. * perf(perf): add key= to Lazy* list items for stable identity - SearchScreen: key groups by titleRes, items by toString() - ListPickerDialog, MultiListPickerDialog: key items by toString() - LayoutPickerDialog: key by layout name - ToolbarKeysCustomizer: key by enum name - ColorThemePickerDialog: key by color name Without keys, LazyColumn uses positional keys, causing every visible item to be recomposed (and its remember slots discarded) on every search keystroke or list mutation. * perf(perf): remember() expensive computations in Composables - SearchScreen: cache filteredItems(searchText.text) so it doesn't re-run the search filter on every parent recomposition (only when the search text actually changes) - MainSettingsScreen: cache SubtypeSettings.getEnabledSubtypes() and its joinToString() output so the description string is not rebuilt on every recomposition Both lists are otherwise recomputed on every pref change, every parent state change, and every scroll-induced recomposition. * perf(perf): avoid Paint allocation per recomposition in ColorPickerDialog Wrap the Paint in remember { } and assign to the controller inside a LaunchedEffect so the Paint is created once and not allocated on every recomposition. Also avoids re-assigning the controller's wheelPaint on every recomposition. * perf(perf): stream logcat to file instead of buffering in memory AboutScreen's 'Save log' was reading the entire logcat buffer into a single String via readText(), then writing it out. For a long-running device this can be several MB and compete with the IME process for memory, risking OOM on low-RAM devices. Use useLines { } to iterate line by line and write each one directly to the output stream. The internal log is now also streamed with a for loop and explicit toString() instead of a joinToString() that builds the entire list as a single String. * perf(perf): make ReorderSwitchPreference data class stable The private KeyAndState class had var fields that were mutated in the Switch.onCheckedChange callback, defeating Compose stability and forcing LazyColumn items to be rebuilt on every recomposition. - Make KeyAndState an immutable data class annotated with @Immutable - Hold the checked state in rememberSaveable(item.name) so the value survives recomposition but is per-item - Remove the in-place mutation of item.state in the Switch callback - rememberSaveable the items list so it's not re-parsed on every recomposition when the dialog is open * fix(perf): remove top-level MutableStateFlow in AIIntegrationScreen The previous top-level 'providerState' MutableStateFlow lived for the process lifetime and was mutated during composition. The state can be derived from the service on every composition (the service reads from SharedPreferences, which is cheap). - Replace top-level MutableStateFlow with a simple val read - Remove the no-op updateProviderState() function - Remove its call site in AdvancedScreen.kt The AIIntegrationScreen will pick up provider changes on the next composition (e.g. when the user navigates to it after changing the provider on the AdvancedScreen). * fix(perf): scope errorJob to the LayoutEditDialog composable The top-level 'private var errorJob: Job?' was shared between any two simultaneous instances of LayoutEditDialog, so opening a second dialog would cancel the first dialog's pending error feedback job. On configuration change the coroutine scope could be cancelled while the top-level job reference was leaked. Move the job into a per-composable remember { mutableStateOf(null) } and cancel/assign through errorJob.value. * fix(perf): replace GlobalScope in toolbar preference listener setToolbarButtonsActivatedStateOnPrefChange used GlobalScope.launch to defer a UI update by 10 ms, waiting for SettingsValues to reload after a SharedPreferences change. GlobalScope is uncancellable and its default exception handler converts failures into silent crashes. Replace it with a process-wide scope that uses SupervisorJob (so one failure cannot tear down sibling preference updates) and a logging CoroutineExceptionHandler. The function still hops to Dispatchers.Main before touching the view tree. * fix(perf): make SettingsNavHost navigateTo scope supervised The CoroutineScope backing the navigateTo() helper used a plain Job, so a single child failure would cancel the scope permanently. Add SupervisorJob so unrelated navigation hops keep working. * fix(stability): replace !! in colorFilter() helper createBlendModeColorFilterCompat returns a nullable ColorFilter, but the helper is only ever called with the supported BlendModeCompat modes (MODULATE, SRC_IN). Replace the !! with a Kotlin error() that throws IllegalStateException with a useful message if a new unsupported mode is ever introduced. * perf(perf): cache main-thread Handler in ClipboardHistoryManager ClipboardHistoryManager is a singleton scoped to the IME service, but it was creating a fresh Handler(Looper.getMainLooper()) on every postDelayed() and on every ContentObserver registration. The main Looper is process-wide and lives for the lifetime of the app, so a single cached Handler is enough. Replace the two ad-hoc Handler allocations in registerMediaStoreObserver and in the post-paste clip restoration path with a single 'mainHandler' field on the manager. * fix(stability): use SupervisorJob in RichInputMethodManager scope The CoroutineScope backing updateShortcutIme, onSubtypeChanged and related fire-and-forget coroutines was using a plain Job. A single exception in any of those coroutines would cancel the scope and stop all subsequent subtype lookups for the lifetime of the IME process. Add SupervisorJob() so a single failure cannot tear down the rest of the lookups. * fix(stability): use ContextCompat.registerReceiver with NOT_EXPORTED flag LatinIME.onCreate was using the deprecated registerReceiver(receiver, filter) overload for the ringer mode, package add/remove and user unlocked broadcasts. On Android 13+ this throws SecurityException unless the receiver is registered with an explicit exported flag. Switch the three call sites to ContextCompat.registerReceiver with RECEIVER_NOT_EXPORTED, matching the existing style used for DICTIONARY_DUMP_INTENT_ACTION. The exported flag stays set for the NEW_DICTIONARY_INTENT_ACTION receiver, as documented in the existing comment, because the sender app may not be this one. * fix(settings): update ai provider fields dynamically Align provider preference source and observe changes dynamically to update UI fields immediately. Wrap preferences in key() to prevent Compose state reuse. * feat: add standardOptimised flavor and disable r8 Add standardOptimised flavor to allow non-reproducible optimizations like R8 fullMode and baseline profiles. Turn off R8 fullMode globally to restore reproducibility for standard flavor on F-Droid. Clean APK metadata and restore global V2/V3 signing. * perf: add baseline profile for standardOptimised Add manual wildcard-based precompilation rules in baseline-prof.txt for standardOptimised to optimize startup, typing reaction, and suggestions. Fix dynamic property injection in settings.gradle. * feat: remove standardOptimised package suffix Remove applicationIdSuffix from standardOptimised product flavor to share standard package name (com.leanbitlab.leantype). * fix: dismiss emoji dialog and update wizard status Close ConfirmationDialog on successful emoji dictionary download/load. Invoke onSuccess callback in WelcomeWizard to trigger recomposition and show the checkmark immediately. * Update ar.txt * Update build-debug-apk.yml * arabic-popup-and-harakat-tweak * arabic-popup-and-harakat-tweak V2 * ci: add badge update workflow * chore: update README badges [skip ci] * fix: strip leading v from version tag * chore: update README badges [skip ci] * fix(badges): adjust width and add viewBox attributes to prevent clipping * chore: update README badges [skip ci] * chore(badges): rename download badge label to version * chore: update README badges [skip ci] * fix: prevent duplicate screenshots in clipboard * feat: add toggle for screenshot compression * feat: improve text expander, gestures, and emoji scale fit * chore: update README badges [skip ci] * fix: persist toolbar customizer key toggles * build: bump version to v3.8.5 * feat: toggle dictionaries individually * chore: add changelog for v3.8.5 * fix: split emoji search keyboard layout * chore: update changelog for emoji search fix * added auto detect feature * changed registration flag * chore: update README badges [skip ci] * Update ar.txt * refactor: replace onnxruntime with llamacpp Switches offline proofreader to llamacpp-kotlin GGUF and updates model settings UI to resolve 16 KB page alignment compatibility warnings. * suggestion delete blacklist always. reload blacklist interface add. * blocked words screen add. dictionary screen integration done. settings strings update. * blacklist check case-insensitive. lowercase canonicalization added. user dictionary suggestion leak resolved. * blacklist regex support added. compiled patterns cached. compile-time receiver errors resolved. * SearchScreen remember key fix. filteredItems lambda dependency added. list auto-refresh working. * fix(layout): align Arabic diacritics spacing * chore: update README badges [skip ci] * chore: update README badges [skip ci] * chore: update README badges [skip ci] * chore: update README badges [skip ci] * chore: update README badges [skip ci] * Allow for reasoning models; handle structured content arrays in API responses Parse JSONArray content format with type and text fields. Extract reasoning_content when main content is blank. Fall back to firstChoice text field if content extraction fails. * feat: add regex expander & fix dictionary crash * feat(touchpad): double tap to select word & fix emoji popup preview * feat(offline): add settings for custom sampling & prompt * chore: update gitignore - add .env, .pi/ and remove duplicate * docs: add F-Droid reproducibility delay notice * chore: bump version to v3.8.6 and add changelog * fix(touchpad): always select word on double tap and update docs * feat(touchpad): implement multi-finger gestures and update docs * feat(touchpad): reorganize gestures for intuitive rich text editing layout * feat(touchpad): migrate gestures to 1 and 2 fingers * docs: update features for llama.cpp migration * docs: note model-dependent accuracy in features * fix(touchpad): exit touchpad mode when opening clipboard or emoji * perf(offline): optimize proofreading latency and load times * fix(offline): improve GGUF prompt formatting and output cleaning * fix(offline): truncate model output at template markers and add native stop sequences * fix(offline): implement dynamic target-language-specific few-shot examples for GGUF translation * feat(expander): immediate expand & fix revert * feat: hold toolbar arrow keys to auto-repeat * chore: update README badges [skip ci] * chore: update README badges [skip ci] * chore: update README badges [skip ci] * feat: add toggle for insecure AI connections Allows HTTP local endpoints and self-signed HTTPS connections only when explicitly enabled by the user. * feat: add selective backup and restore * fix: allow same word with different shortcuts * feat: strip spaces before punctuation marks * chore: update README badges [skip ci] * chore: update README badges [skip ci] * chore: update README badges [skip ci] * chore: update README badges [skip ci] * Change default popup key on letter ا in Persian language * chore: update README badges [skip ci] * feat: add handwriting input support * docs: update 3.8.6 changelog * fix: use wildcard mime type for file picker to avoid waydroid crash * fix: clear code cache directory on plugin import/remove * chore: add MD5 hash and size logging for loaded plugin * feat(handwriting): fix crash and dynamic model downloading Move model readiness checks to background thread to prevent main thread blocking exceptions. Add ML Kit client dependencies to standard build flavor for native library alignment. Auto-upgrade toolbar preferences to discover new keys without factory resets. * feat: fix handwriting layout, theming and logic * style: change handwriting toolbar icon color to white * feat: show shortcut overlay on handwriting canvas when plugin missing * fix(ai): resolve offline custom key token loss Prevent token loss and hallucination in local models due to formatting and JNI bugs. * feat(handwriting): add plugin downloader and refine blacklist * build: limit abi filters to arm64-v8a * docs: document handwriting and gguf features * chore: update README badges [skip ci] * chore: update README badges [skip ci] * fix(handwriting): avoid cancelling active keys when hidden * build: update config, proguard rules, and blacklist parsing * feat: tune double-tap shift timing and keep llamacpp proguard * build: remove standardOptimised flavor * fix: prevent handwriting suggestions from hiding Also hide the redundant top toolbar on the handwriting panel during normal use, keeping it only for active download progress. * chore: update README badges [skip ci] * fix(toolbar): restore close/search on clipboard * fix(settings): add label for clipboard_search key * feat(settings): allow deleting handwriting model * feat(dict): add dynamic dictionary downloader * feat(dict): allow uninstalling downloaded dicts * feat(dict): improve dynamic downloading flow * fix: keep number row digits when keyboard is shifted The number row layout used a shift_state_selector whose manualOrLocked branch rendered the shifted symbol (!@#...) in place of the digit, so engaging shift or shift-lock replaced 1234567890 with !@#$%^&*(). The keys are now plain digit keys in every shift state, with the shifted symbol kept as the first popup ahead of the existing fraction popups. Fixes #180 * feat(dict): exclude non-en-US dictionaries from standard flavor assets * feat(dict): show download button on toolbar if layout dictionary is not loaded * fix: do not show disabled additional subtypes in dict settings list * chore: add v3.8.7 and v3.8.8 changelogs * fix: prevent WindowManager$BadTokenException in IME overlay dialog * fix: only update split toolbar emoji recents when view is visible * feat(emoji): close search on dictionary download * feat(emoji): show download button in split toolbar * chore: update changelog for 3.8.8 * chore: bump version to 3.8.8 * feat(handwriting): add download button to plugin required overlay * chore: add handwriting plugin downloader to 3.8.8 changelog * docs: temporarily hide F-Droid badge from README * docs: remove F-Droid column from table to fix spacing * docs: move download section above screenshots in README * docs: remove fork AI feature description line from README * docs: add Dynamic Downloader to README features * docs: add Selective Backup, Blacklist, and OTP features to README * docs: sort features by significance in README * chore: update README badges [skip ci] --------- Co-authored-by: LeanBitLab Co-authored-by: iBasim <57762287+iBasim@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Rohan Lodhi <42321434+RohanLodhi@users.noreply.github.com> Co-authored-by: LeanBitLab Co-authored-by: David C Co-authored-by: nugraha-abd <62243267+nugraha-abd@users.noreply.github.com> Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- .github/workflows/update-badges.yml | 78 ----- CHANGELOG.md | 16 +- README.md | 3 + app/build.gradle.kts | 39 ++- app/proguard-rules.pro | 19 ++ .../assets/layouts/number_row/number_row.json | 58 +--- .../keyboard/keyboard/TouchpadView.java | 3 + .../keyboard/emoji/EmojiPalettesView.java | 49 ++- .../keyboard/internal/KeyboardState.kt | 7 +- .../keyboard/internal/TimerHandler.java | 5 +- .../latin/DictionaryFacilitatorImpl.kt | 9 +- .../helium314/keyboard/latin/LatinIME.java | 15 +- .../handwriting/HandwritingRecognizer.kt | 2 + .../latin/handwriting/HandwritingView.kt | 124 ++++++- .../keyboard/latin/inputlogic/InputLogic.java | 31 -- .../latin/suggestions/SuggestionStripView.kt | 145 ++++++-- .../keyboard/latin/utils/DictionaryUtils.kt | 274 ++++++++++++++- .../keyboard/latin/utils/ToolbarUtils.kt | 23 +- .../keyboard/settings/WelcomeWizard.kt | 2 +- .../settings/dialogs/ConfirmationDialog.kt | 25 ++ .../settings/dialogs/DictionaryDialog.kt | 47 ++- .../dialogs/ThreeButtonAlertDialog.kt | 155 +++++---- .../settings/screens/AIIntegrationScreen.kt | 2 +- .../settings/screens/AdvancedScreen.kt | 2 +- .../settings/screens/DictionaryScreen.kt | 17 + .../settings/screens/LibrariesHubScreen.kt | 3 +- .../settings/screens/SubtypeScreen.kt | 42 +++ .../settings/screens/ToolbarScreen.kt | 4 +- app/src/main/res/layout/handwriting_view.xml | 11 +- app/src/main/res/values/strings.xml | 9 + .../keyboard/latin/utils/ProofreadService.kt | 312 +++++++++--------- .../keyboard/latin/utils/ProofreadService.kt | 2 + .../keyboard/latin/utils/ProofreadService.kt | 12 +- .../helium314/keyboard/KeyboardParserTest.kt | 23 ++ docs/badges/download.svg | 1 - docs/badges/downloads.svg | 1 - docs/badges/stars.svg | 1 - .../android/en-US/changelogs/3860.txt | 2 +- .../android/en-US/changelogs/3870.txt | 3 + .../android/en-US/changelogs/3880.txt | 13 + 40 files changed, 1083 insertions(+), 506 deletions(-) delete mode 100644 .github/workflows/update-badges.yml delete mode 100644 docs/badges/download.svg delete mode 100644 docs/badges/downloads.svg delete mode 100644 docs/badges/stars.svg create mode 100644 fastlane/metadata/android/en-US/changelogs/3870.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/3880.txt diff --git a/.github/workflows/update-badges.yml b/.github/workflows/update-badges.yml deleted file mode 100644 index a3f5715c1..000000000 --- a/.github/workflows/update-badges.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Update README Badges - -on: - schedule: - - cron: '0 0 * * *' # Midnight UTC - workflow_dispatch: - -permissions: - contents: write - -jobs: - update-badges: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Fetch GitHub stats - id: stats - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - REPO="LeanBitLab/HeliboardL" - - # Latest version - VERSION=$(gh api repos/$REPO/releases/latest --jq '.tag_name' | sed 's/^v//' 2>/dev/null || echo "N/A") - echo "version=$VERSION" >> $GITHUB_OUTPUT - - # Total downloads - DOWNLOADS=$(gh api repos/$REPO/releases --jq '[.[].assets[]?.download_count] | add // 0' 2>/dev/null || echo "0") - echo "downloads=$DOWNLOADS" >> $GITHUB_OUTPUT - - # Stars - STARS=$(gh api repos/$REPO --jq '.stargazers_count' 2>/dev/null || echo "0") - echo "stars=$STARS" >> $GITHUB_OUTPUT - - - name: Generate badge SVGs - env: - VERSION: ${{ steps.stats.outputs.version }} - DOWNLOADS: ${{ steps.stats.outputs.downloads }} - STARS: ${{ steps.stats.outputs.stars }} - run: | - mkdir -p docs/badges - - # Format numbers with commas - DOWNLOADS_FMT=$(printf "%'d" "$DOWNLOADS" 2>/dev/null || echo "$DOWNLOADS") - STARS_FMT=$(printf "%'d" "$STARS" 2>/dev/null || echo "$STARS") - - # Download version badge - cat > docs/badges/download.svg << EOF - VersionVersionv${VERSION}v${VERSION} - EOF - - # Downloads count badge - cat > docs/badges/downloads.svg << EOF - DownloadsDownloads${DOWNLOADS_FMT}${DOWNLOADS_FMT} - EOF - - # Stars badge - cat > docs/badges/stars.svg << EOF - StarsStars${STARS_FMT}${STARS_FMT} - EOF - - echo "Generated: v$VERSION | $DOWNLOADS_FMT downloads | $STARS_FMT stars" - - - name: Update README badge URLs - run: | - # Replace shields.io URLs with local badge paths - sed -i 's|https://img.shields.io/github/v/release/LeanBitLab/HeliboardL?label=Download\&style=for-the-badge\&color=7C4DFF|docs/badges/download.svg|g' README.md - sed -i 's|https://img.shields.io/github/downloads/LeanBitLab/HeliboardL/total?style=for-the-badge\&color=7C4DFF\&label=Downloads|docs/badges/downloads.svg|g' README.md - sed -i 's|https://img.shields.io/github/stars/LeanBitLab/HeliboardL?style=for-the-badge\&color=7C4DFF|docs/badges/stars.svg|g' README.md - - - name: Commit changes - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add docs/badges/ README.md - git diff --staged --quiet || git commit -m "chore: update README badges [skip ci]" - git push diff --git a/CHANGELOG.md b/CHANGELOG.md index 566105ea0..2823bd71e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - **Auto-read OTP from SMS** — a one-time code from an incoming SMS is offered in the suggestion strip while the keyboard is open; tap to insert. Uses a runtime, opt-in SMS permission. - **Regex shortcuts in Text Expander** — expansion triggers can be matched by regular expression. +- **Dynamic dictionary/plugin downloader** — Standard builds can fetch layout dictionaries, emoji dictionaries, and handwriting plugins on demand. +- **Selective backup and restore** — backup/restore settings, dictionaries, and AI prompt configuration more granularly. ### Changed - **Offline AI backend switched from ONNX Runtime to llama.cpp (GGUF).** The Offline build now @@ -31,11 +33,19 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) navigation, space, copy/paste, cut/select-all, undo/redo, hold-to-backspace). Single-finger double-tap now **selects the word** (previously deleted the selection). - Release builds now target the **arm64-v8a** ABI only. +- Standard builds now exclude non-en-US dictionary assets and download optional dictionaries dynamically. + +### Fixed +- **Sticky Shift from upstream handwriting cleanup** — upstream v3.8.6 stopped the hidden handwriting + bottom row on every keyboard-frame switch, which globally cancelled the active Shift pointer before + release. We keep the upstream handwriting feature but only stop handwriting when it is actually + shown. (Upstream bug LeanBitLab/LeanType#186; upstream PR #194.) ### Upstream -- Merged **LeanBitLab/LeanType v3.8.6** (from v3.8.3) — the source of the handwriting, - llama.cpp/GGUF, touchpad-gesture, and SMS-OTP changes above. Fork identity (LeanTypeDual, distinct - `applicationId`, two-thumb typing, the Gemini standard-AI layer, and the privacy tiers) is +- Merged **LeanBitLab/LeanType v3.8.8** (from v3.8.3, including v3.8.7 and two post-tag docs/badge + commits) — the source of the handwriting, llama.cpp/GGUF, dynamic downloader, touchpad-gesture, + SMS-OTP, selective-backup, and dictionary-downloader changes above. Fork identity (LeanTypeDual, + distinct `applicationId`, two-thumb typing, the Gemini standard-AI layer, and the privacy tiers) is preserved. ## [3.9.1] - 2026-06-11 diff --git a/README.md b/README.md index 988fcbe81..095bd0754 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult - **🧠 Smarter learned words** - *graduated trust* keeps a just-learned word below real-dictionary suggestions until you've used it a few times (no premature autocorrect to half-typed words); flag unknown words to **Add** or **Block** them via a Blocklist screen. - **↩️ Undo word** - a toolbar key that reverts the last committed word back to its suggestion alternatives. - **🗂️ Per-dictionary control** - enable or disable individual built-in and custom dictionaries. +- **📥 Dynamic Downloader** - Standard builds can download layout dictionaries, emoji dictionaries, and handwriting plugins on demand, keeping the initial app smaller. - **🪟 Floating Keyboard** - Detach the keyboard into a draggable, resizable window (true OS-level overlay), with an optional persistent mode. - **⌨️ Dual Toolbar / Split Suggestions** - Split the suggestion strip and toolbar for easier reach. - **🖱️ Touchpad Mode** - Swipe the spacebar up for a cursor touchpad with sensitivity controls and edge-scroll acceleration, including a full-screen laptop-style mode. @@ -37,6 +38,8 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult - **🔄 Google Dictionary Import** - Import your personal dictionary words. - **🔍 Clipboard Search & Undo** - Search clipboard history from the toolbar, undo accidental deletions, and fold pinned items by default. - **📸 Screenshot Suggestion & Clipboard** - Recently-taken screenshots are offered in the suggestion strip and saved to clipboard history. +- **✉️ Auto-Read OTP** - Incoming one-time codes can appear in the suggestion strip for quick insertion. +- **💾 Selective Backup & Restore** - Backup and restore settings, dictionaries, and AI prompt configuration selectively. - **🔎 Emoji Search** - Search emojis by name. *Requires loading an Emoji Dictionary.* - **⚙️ Enhanced Customization** - Force auto-capitalization, fine-grained haptics, distinct incognito icon, reorganized settings, and more. - **🔒 Privacy Choices** - Choose **Standard** (opt-in AI, handwriting), **Offline** (network hard-disabled, offline GGUF model), or **Offline Lite** (no AI, ~20 MB). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8da584599..f16260ef6 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -36,9 +36,7 @@ android { productFlavors { create("standard") { dimension = "privacy" - } - create("standardOptimised") { - dimension = "privacy" + minSdk = 23 } create("offline") { dimension = "privacy" @@ -106,7 +104,6 @@ android { "standard" -> "1" "offline" -> "2" "offlinelite" -> "3" - "standardOptimised" -> "4" else -> "" } if (number.isNotEmpty()) { @@ -119,13 +116,28 @@ android { } // got a little too big for GitHub after some dependency upgrades, so we remove the largest dictionary androidComponents.onVariants { variant: ApplicationVariant -> + val patterns = mutableListOf() if (variant.buildType == "debug") { - variant.androidResources.ignoreAssetsPatterns = listOf("main_ro.dict") + patterns.add("main_ro.dict") variant.proguardFiles = emptyList() //noinspection ProguardAndroidTxtUsage we intentionally use the "normal" file here variant.proguardFiles.add(project.layout.buildDirectory.file(getDefaultProguardFile("proguard-android.txt").absolutePath)) variant.proguardFiles.add(project.layout.buildDirectory.file(project.buildFile.parent + "/proguard-rules.pro")) } + if (variant.flavorName == "standard") { + // ponytail: dynamically find all dict files to ignore in standard flavor except main_en-US.dict + val dictsDir = project.file("src/main/assets/dicts") + if (dictsDir.exists() && dictsDir.isDirectory) { + dictsDir.listFiles()?.forEach { file -> + if (file.name.endsWith(".dict") && file.name != "main_en-US.dict") { + patterns.add(file.name) + } + } + } + } + if (patterns.isNotEmpty()) { + variant.androidResources.ignoreAssetsPatterns = patterns + } } } @@ -194,14 +206,6 @@ android { // these orphaned strings are harmlessly stripped by R8 during minification. disable += "ExtraTranslation" } - - sourceSets { - getByName("standardOptimised") { - java.srcDirs("src/standard/java") - res.srcDirs("src/standard/res") - manifest.srcFile("src/standard/AndroidManifest.xml") - } - } } dependencies { @@ -230,8 +234,6 @@ dependencies { // gemini ai proofreading "standardImplementation"("com.google.ai.client.generativeai:generativeai:0.9.0") "standardImplementation"("androidx.security:security-crypto:1.1.0-alpha06") // for encrypted API key storage - "standardOptimisedImplementation"("com.google.ai.client.generativeai:generativeai:0.9.0") - "standardOptimisedImplementation"("androidx.security:security-crypto:1.1.0-alpha06") // local llm proofreading (offline) "offlineImplementation"("io.github.ljcamargo:llamacpp-kotlin:0.4.0") @@ -248,7 +250,6 @@ dependencies { // ML Kit's internal asset manager and native library loader use the host app context, // so the host app must compile and include the client library resources/libraries. "standardImplementation"("com.google.mlkit:digital-ink-recognition:19.0.0") - "standardOptimisedImplementation"("com.google.mlkit:digital-ink-recognition:19.0.0") // test testImplementation(kotlin("test")) @@ -267,11 +268,9 @@ dependencies { "runTestsImplementation"("androidx.compose.ui:ui-test-manifest") } -// Disable baseline/ART profile tasks to guarantee deterministic reproducible builds (except for standardOptimised) +// Disable baseline/ART profile tasks to guarantee deterministic reproducible builds tasks.configureEach { if (name.contains("ArtProfile", ignoreCase = true)) { - if (!name.contains("StandardOptimised", ignoreCase = true)) { - enabled = false - } + enabled = false } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 50d6ba6d4..97b947a73 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -27,6 +27,8 @@ # Keep java-llama.cpp classes -keep class de.kherud.llama.** { *; } +-keep class org.nehuatl.llamacpp.** { *; } + # Fix correct service name @@ -36,3 +38,20 @@ -dontwarn com.google.api.client.** -dontwarn java.lang.management.** -dontwarn org.joda.time.** + +# Keep handwriting plugin interface and listener to prevent parameter removal/signature optimization +-keep interface helium314.keyboard.latin.handwriting.HandwritingRecognizer { + ; +} +-keep interface helium314.keyboard.latin.handwriting.ModelDownloadListener { + ; +} + +# Keep ML Kit, GMS Tasks, and Firebase components for handwriting plugin dynamic linkage +-keep class com.google.mlkit.** { *; } +-keep class com.google.android.gms.tasks.** { *; } +-keep class com.google.firebase.components.** { *; } + +# Keep Kotlin standard library for dynamically loaded plugins +# ponytail: keep kotlin stdlib classes to prevent NoSuchMethodError in plugin loading +-keep class kotlin.** { *; } diff --git a/app/src/main/assets/layouts/number_row/number_row.json b/app/src/main/assets/layouts/number_row/number_row.json index 3ad11861a..8a5ef88aa 100644 --- a/app/src/main/assets/layouts/number_row/number_row.json +++ b/app/src/main/assets/layouts/number_row/number_row.json @@ -1,44 +1,14 @@ -[ - [ - { "$": "shift_state_selector", - "manualOrLocked": { "label": "!" }, - "default": { "label": "1", "popup": { "relevant": [{ "label": "¹" }, { "label": "½" }, { "label": "⅓" }, { "label": "¼" }, { "label": "⅛" }] } } - }, - { "$": "shift_state_selector", - "manualOrLocked": { "label": "@" }, - "default": { "label": "2", "popup": { "relevant": [{ "label": "²" }, { "label": "⅔" }] } } - }, - { "$": "shift_state_selector", - "manualOrLocked": { "label": "#" }, - "default": { "label": "3", "popup": { "relevant": [{ "label": "³" }, { "label": "¾" }, { "label": "⅜" }] } } - }, - { "$": "shift_state_selector", - "manualOrLocked": { "label": "$" }, - "default": { "label": "4", "popup": { "relevant": [{ "label": "⁴" }] } } - }, - { "$": "shift_state_selector", - "manualOrLocked": { "label": "%" }, - "default": { "label": "5", "popup": { "relevant": [{ "label": "⁵" }, { "label": "⅝" }] } } - }, - { "$": "shift_state_selector", - "manualOrLocked": { "label": "^" }, - "default": { "label": "6", "popup": { "relevant": [{ "label": "⁶" }] } } - }, - { "$": "shift_state_selector", - "manualOrLocked": { "label": "&" }, - "default": { "label": "7", "popup": { "relevant": [{ "label": "⁷" }, { "label": "⅞" }] } } - }, - { "$": "shift_state_selector", - "manualOrLocked": { "label": "*" }, - "default": { "label": "8", "popup": { "relevant": [{ "label": "⁸" }] } } - }, - { "$": "shift_state_selector", - "manualOrLocked": { "label": "(" }, - "default": { "label": "9", "popup": { "relevant": [{ "label": "⁹" }] } } - }, - { "$": "shift_state_selector", - "manualOrLocked": { "label": ")" }, - "default": { "label": "0", "popup": { "relevant": [{ "label": "⁰" }, { "label": "ⁿ" }, { "label": "∅" }] } } - } - ] - ] \ No newline at end of file +[ + [ + { "label": "1", "popup": { "relevant": [{ "label": "!" }, { "label": "¹" }, { "label": "½" }, { "label": "⅓" }, { "label": "¼" }, { "label": "⅛" }] } }, + { "label": "2", "popup": { "relevant": [{ "label": "@" }, { "label": "²" }, { "label": "⅔" }] } }, + { "label": "3", "popup": { "relevant": [{ "label": "#" }, { "label": "³" }, { "label": "¾" }, { "label": "⅜" }] } }, + { "label": "4", "popup": { "relevant": [{ "label": "$" }, { "label": "⁴" }] } }, + { "label": "5", "popup": { "relevant": [{ "label": "%" }, { "label": "⁵" }, { "label": "⅝" }] } }, + { "label": "6", "popup": { "relevant": [{ "label": "^" }, { "label": "⁶" }] } }, + { "label": "7", "popup": { "relevant": [{ "label": "&" }, { "label": "⁷" }, { "label": "⅞" }] } }, + { "label": "8", "popup": { "relevant": [{ "label": "*" }, { "label": "⁸" }] } }, + { "label": "9", "popup": { "relevant": [{ "label": "(" }, { "label": "⁹" }] } }, + { "label": "0", "popup": { "relevant": [{ "label": ")" }, { "label": "⁰" }, { "label": "ⁿ" }, { "label": "∅" }] } } + ] +] diff --git a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java index e1151832f..6d955ffeb 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java @@ -376,6 +376,8 @@ private void setupTouchSurface() { mIsTwoFingerTap = false; removeCallbacks(mTwoFingerLongPressRunnable); mIsTwoFingerLongPress = false; + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); if (mSelectionMode) { mSelectionMode = false; applySurfaceColor(); @@ -385,6 +387,7 @@ private void setupTouchSurface() { case MotionEvent.ACTION_CANCEL: android.util.Log.i("TouchpadView", "ACTION_CANCEL"); mIsDragging = false; + stopEdgeScrolling(); mIsTwoFingerScroll = false; mIsTwoFingerTap = false; removeCallbacks(mTwoFingerLongPressRunnable); diff --git a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java index b83c53b10..6a9770945 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java @@ -233,6 +233,7 @@ private void updateState(@NonNull RecyclerView recyclerView, long categoryId) { private EmojiSearchAdapter mSearchAdapter; private EditText mSearchBar; private boolean mInSearchMode = false; + private boolean mIsDownloadingEmojiDict = false; private KeyboardActionListener mOriginalActionListener; private EditorInfo mEditorInfo; @@ -496,13 +497,14 @@ public void afterTextChanged(Editable s) { if (Settings.getValues().mSplitToolbar) { // Do not add results to this row, they go to SuggestionStripView mSearchAdapter = null; + updateSplitToolbarEmojiSuggestions(); } else if (sDictionaryFacilitator == null) { Button downloadBtn = new Button(ctx); downloadBtn.setText("Download Dictionary"); downloadBtn.setTextSize(12); // Keep it small to fit downloadBtn.setAllCaps(false); downloadBtn.setOnClickListener(v -> { - if ("standard".equals(BuildConfig.FLAVOR) || "standardOptimised".equals(BuildConfig.FLAVOR)) { + if ("standard".equals(BuildConfig.FLAVOR)) { downloadEmojiDictionary(); downloadBtn.setText("Downloading..."); downloadBtn.setEnabled(false); @@ -762,7 +764,11 @@ private void performSearch(String query) { mSearchAdapter.submitList(java.util.Collections.emptyList()); // In split mode, restore recents on suggestion bar when search is empty if (Settings.getValues().mSplitToolbar) { - populateSuggestionBarWithRecents(); + if (sDictionaryFacilitator == null) { + updateSplitToolbarEmojiSuggestions(); + } else { + populateSuggestionBarWithRecents(); + } } return; } @@ -914,7 +920,8 @@ private void addRecentKey(final Key key) { if (mPager != null && mPager.getAdapter() != null) { mPager.getAdapter().notifyItemChanged(mEmojiCategory.getRecentTabId()); } - if (split) { + // ponytail: only update suggestion bar if the emoji view is actually visible + if (split && isShown()) { populateSuggestionBarWithRecents(); } } @@ -934,7 +941,8 @@ public void addRecentKey(final String emoji) { if (mPager != null && mPager.getAdapter() != null) { mPager.getAdapter().notifyItemChanged(mEmojiCategory.getRecentTabId()); } - if (split) { + // ponytail: only update suggestion bar if the emoji view is actually visible + if (split && isShown()) { populateSuggestionBarWithRecents(); } } @@ -1023,6 +1031,35 @@ private void pushEmojisToSuggestionBar(java.util.List emojis) { }); } + private void updateSplitToolbarEmojiSuggestions() { + SuggestionStripView stripView = KeyboardSwitcher.getInstance().getSuggestionStripView(); + if (stripView == null) + return; + + if (sDictionaryFacilitator == null) { + // ponytail: show download button on suggestion strip in split mode if dictionary is missing + stripView.setEmojiDownloadButton(() -> { + if ("standard".equals(BuildConfig.FLAVOR)) { + downloadEmojiDictionary(); + mIsDownloadingEmojiDict = true; + updateSplitToolbarEmojiSuggestions(); + } else { + Context ctx = getContext(); + Intent intent = new Intent(ctx, SettingsActivity.class); + intent.putExtra("screen", "dictionaries"); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + ctx.startActivity(intent); + } + }, mIsDownloadingEmojiDict); + } else { + if (mSearchBar != null && !TextUtils.isEmpty(mSearchBar.getText())) { + performSearch(mSearchBar.getText().toString()); + } else { + populateSuggestionBarWithRecents(); + } + } + } + public void setKeyboardActionListener(final KeyboardActionListener listener) { mKeyboardActionListener = listener; } @@ -1154,9 +1191,10 @@ private void downloadEmojiDictionary() { new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> { Toast.makeText(getContext(), "Emoji dictionary installed!", Toast.LENGTH_SHORT).show(); initDictionaryFacilitator(); + mIsDownloadingEmojiDict = false; if (mInSearchMode) { + // ponytail: close search mode automatically on successful dictionary download stopSearchMode(); - startSearchMode(); } }); } else { @@ -1166,6 +1204,7 @@ private void downloadEmojiDictionary() { android.util.Log.e("EmojiSearch", "Failed to download dictionary", e); new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> { Toast.makeText(getContext(), "Failed to download dictionary", Toast.LENGTH_SHORT).show(); + mIsDownloadingEmojiDict = false; if (mInSearchMode) { stopSearchMode(); startSearchMode(); diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt index 9408fe0af..384bf644e 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt @@ -73,6 +73,8 @@ class KeyboardState(private val switchActions: SwitchActions) { // For handling double tap. private var isInAlphabetUnshiftedFromShifted = false private var isInDoubleTapShiftKey = false + private var lastShiftPressTime = 0L + private val savedKeyboardState = SavedKeyboardState() @@ -515,8 +517,11 @@ class KeyboardState(private val switchActions: SwitchActions) { shiftKeyState.onPress() return } - isInDoubleTapShiftKey = switchActions.isInDoubleTapShiftKeyTimeout + val now = android.os.SystemClock.uptimeMillis() + isInDoubleTapShiftKey = switchActions.isInDoubleTapShiftKeyTimeout && (now - lastShiftPressTime > 100) + lastShiftPressTime = now if (isInDoubleTapShiftKey) { + if (alphabetShiftState.isManualShifted || isInAlphabetUnshiftedFromShifted) { // Shift key has been double tapped while in manual shifted or automatic shifted state. setShiftLocked(true) diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/TimerHandler.java b/app/src/main/java/helium314/keyboard/keyboard/internal/TimerHandler.java index e47a42361..c6a3fb374 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/TimerHandler.java +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/TimerHandler.java @@ -163,10 +163,10 @@ public boolean isTypingState() { @Override public void startDoubleTapShiftKeyTimer() { - sendMessageDelayed(obtainMessage(MSG_DOUBLE_TAP_SHIFT_KEY), - ViewConfiguration.getDoubleTapTimeout()); + sendMessageDelayed(obtainMessage(MSG_DOUBLE_TAP_SHIFT_KEY), 300); } + @Override public void cancelDoubleTapShiftKeyTimer() { removeMessages(MSG_DOUBLE_TAP_SHIFT_KEY); @@ -218,6 +218,7 @@ public void postDismissGestureFloatingPreviewText(final long delay) { public void cancelAllMessages() { cancelAllKeyTimers(); + cancelDoubleTapShiftKeyTimer(); cancelAllUpdateBatchInputTimers(); removeMessages(MSG_DISMISS_KEY_PREVIEW); removeMessages(MSG_DISMISS_GESTURE_FLOATING_PREVIEW_TEXT); diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index 8d69b59a8..28bbc9c8d 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -995,7 +995,11 @@ private class DictionaryGroup( private var compiledBlacklistPatterns: List = emptyList() private fun rebuildCompiledPatterns() { - compiledBlacklistPatterns = blacklist.map { pattern -> + rebuildCompiledPatterns(blacklist) + } + + private fun rebuildCompiledPatterns(patterns: Collection) { + compiledBlacklistPatterns = patterns.map { pattern -> try { Regex(pattern, RegexOption.IGNORE_CASE) } catch (e: Exception) { @@ -1003,7 +1007,6 @@ private class DictionaryGroup( } } } - private val blacklist = hashSetOf().apply { val file = blacklistFile if (file == null) return@apply @@ -1022,7 +1025,7 @@ private class DictionaryGroup( } } addAll(loadedWords) - rebuildCompiledPatterns() + rebuildCompiledPatterns(this@apply) } catch (e: IOException) { Log.e(TAG, "Exception while trying to read blacklist from ${file.name}", e) } diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index b2577c3c4..589707036 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -295,6 +295,10 @@ public void handleMessage(@NonNull final Message msg) { } public void postUpdateSuggestionStrip(final int inputStyle) { + final LatinIME latinIme = getOwnerInstance(); // ponytail: skip during handwriting + if (latinIme != null && latinIme.mKeyboardSwitcher.isHandwritingShowing()) { + return; + } sendMessageDelayed(obtainMessage(MSG_UPDATE_SUGGESTION_STRIP, inputStyle, 0 /* ignored */), mDelayInMillisecondsToUpdateSuggestions); } @@ -308,6 +312,9 @@ public void postResumeSuggestions(final boolean shouldDelay) { if (latinIme == null) { return; } + if (latinIme.mKeyboardSwitcher.isHandwritingShowing()) { // ponytail: skip during handwriting + return; + } if (!latinIme.mSettings.getCurrent().needsToLookupSuggestions()) { return; } @@ -1682,8 +1689,11 @@ public void setSuggestions(final SuggestedWords suggestedWords) { // should be fine, as there will be another suggestion in a few ms // (but not a great style to avoid this visual glitch, maybe revert this commit // and replace with sth better) - if (suggestedWords.mInputStyle != SuggestedWords.INPUT_STYLE_UPDATE_BATCH) + if (mKeyboardSwitcher.isHandwritingShowing()) { // ponytail: bypass neutral strip/punc lookup + setSuggestedWords(suggestedWords); + } else if (suggestedWords.mInputStyle != SuggestedWords.INPUT_STYLE_UPDATE_BATCH) { setNeutralSuggestionStrip(); + } } else { setSuggestedWords(suggestedWords); } @@ -1768,6 +1778,9 @@ public boolean tryShowClipboardSuggestion() { // and there is a selection of text or it's the start of a line. @Override public void setNeutralSuggestionStrip() { + if (mKeyboardSwitcher.isHandwritingShowing()) { // ponytail: do not override/clear handwriting suggestions + return; + } final SettingsValues currentSettings = mSettings.getCurrent(); if (tryShowOtpSuggestion() || tryShowClipboardSuggestion()) { // an external (OTP or clipboard) suggestion has been set diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt index 308a0ecdf..b50de2392 100644 --- a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt @@ -14,4 +14,6 @@ interface HandwritingRecognizer { fun isLanguageReady(language: String): Boolean fun downloadModel(language: String, listener: ModelDownloadListener) fun recognize(strokes: List): List? + // ponytail: allow deleting downloaded models, default false for backward compatibility + fun removeModel(language: String): Boolean = false } diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt index 39a6227e3..56331ff57 100644 --- a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt @@ -8,6 +8,7 @@ import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import android.widget.ProgressBar import android.graphics.drawable.GradientDrawable import helium314.keyboard.keyboard.KeyboardActionListener import helium314.keyboard.keyboard.KeyboardId @@ -41,6 +42,8 @@ class HandwritingView @JvmOverloads constructor( private lateinit var clearButton: ImageButton private lateinit var canvas: HandwritingCanvas private lateinit var bottomRowKeyboard: MainKeyboardView + private lateinit var downloadProgress: ProgressBar + private var toolbar: View? = null // ponytail: track toolbar private var keyboardActionListener: KeyboardActionListener? = null private var editorInfo: EditorInfo? = null @@ -54,6 +57,8 @@ class HandwritingView @JvmOverloads constructor( clearButton = findViewById(R.id.handwriting_clear_button) canvas = findViewById(R.id.handwriting_canvas) bottomRowKeyboard = findViewById(R.id.handwriting_bottom_row_keyboard) + downloadProgress = findViewById(R.id.handwriting_download_progress) + toolbar = findViewById(R.id.handwriting_toolbar) clearButton.setOnClickListener { clearCanvasAndComposition() @@ -80,9 +85,9 @@ class HandwritingView @JvmOverloads constructor( this.currentLanguage = language val colors = Settings.getValues().mColors - val toolbar = findViewById(R.id.handwriting_toolbar) - if (toolbar != null) { - colors.setBackground(toolbar, ColorType.MAIN_BACKGROUND) + toolbar?.let { + colors.setBackground(it, ColorType.MAIN_BACKGROUND) + it.visibility = View.GONE // ponytail: hide by default to avoid duplicate toolbar/X buttons } colors.setBackground(canvas, ColorType.MAIN_BACKGROUND) @@ -91,6 +96,7 @@ class HandwritingView @JvmOverloads constructor( canvas.setStrokeColor(colors.get(ColorType.KEY_TEXT)) languageLabel.text = language + downloadProgress.visibility = View.GONE // Setup bottom row keyboard bottomRowKeyboard.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn) @@ -137,17 +143,25 @@ class HandwritingView @JvmOverloads constructor( button.background = btnBackground button.setTextColor(colors.get(ColorType.KEY_TEXT)) - button.setOnClickListener { - val intent = android.content.Intent() - intent.setClass(context, helium314.keyboard.settings.SettingsActivity2::class.java) - intent.putExtra("screen", helium314.keyboard.settings.SettingsDestination.Libraries) - intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP - try { - context.startActivity(intent) - } catch (e: Exception) { - Log.e("HandwritingView", "Failed to start settings activity", e) + // ponytail: download plugin directly on standard flavor, otherwise go to Settings + if ("standard" == helium314.keyboard.latin.BuildConfig.FLAVOR) { + button.text = "Download Plugin" + button.setOnClickListener { + downloadPlugin(button) + } + } else { + button.setOnClickListener { + val intent = android.content.Intent() + intent.setClass(context, helium314.keyboard.settings.SettingsActivity2::class.java) + intent.putExtra("screen", helium314.keyboard.settings.SettingsDestination.Libraries) + intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP + try { + context.startActivity(intent) + } catch (e: Exception) { + Log.e("HandwritingView", "Failed to start settings activity", e) + } + KeyboardSwitcher.getInstance().latinIME?.requestHideSelf(0) } - KeyboardSwitcher.getInstance().latinIME?.requestHideSelf(0) } } } else { @@ -161,19 +175,27 @@ class HandwritingView @JvmOverloads constructor( val isReady = recognizer.isLanguageReady(language) mainHandler.post { if (!isReady) { + toolbar?.visibility = View.VISIBLE // ponytail: show for download progress languageLabel.text = "$language (Downloading...)" + downloadProgress.visibility = View.VISIBLE + downloadProgress.progress = 0 recognizer.downloadModel(language, object : ModelDownloadListener { override fun onProgress(progress: Float) { mainHandler.post { - languageLabel.text = "$language (Downloading ${"%.0f".format(progress * 100)}%)" + val percent = (progress * 100).toInt() + languageLabel.text = "$language (Downloading $percent%)" + downloadProgress.progress = percent } } override fun onComplete(success: Boolean) { mainHandler.post { + downloadProgress.visibility = View.GONE if (success) { + toolbar?.visibility = View.GONE // ponytail: hide when done languageLabel.text = language android.widget.Toast.makeText(context, "Handwriting model downloaded", android.widget.Toast.LENGTH_SHORT).show() } else { + toolbar?.visibility = View.VISIBLE languageLabel.text = "$language (Download failed)" android.widget.Toast.makeText(context, "Failed to download handwriting model", android.widget.Toast.LENGTH_LONG).show() } @@ -181,7 +203,9 @@ class HandwritingView @JvmOverloads constructor( } }) } else { + toolbar?.visibility = View.GONE // ponytail: hide when already downloaded languageLabel.text = language + downloadProgress.visibility = View.GONE } } } @@ -365,4 +389,76 @@ class HandwritingView @JvmOverloads constructor( override fun onMoveDeletePointer(steps: Int) { keyboardActionListener?.onMoveDeletePointer(steps) } override fun onUpWithDeletePointerActive() { keyboardActionListener?.onUpWithDeletePointerActive() } override fun resetMetaState() { keyboardActionListener?.resetMetaState() } + + // ponytail: downloads the latest handwriting plugin apk, imports it and updates overlay visibility + private fun downloadPlugin(button: TextView) { + button.text = "Downloading..." + button.isEnabled = false + android.widget.Toast.makeText(context, "Downloading Handwriting Plugin...", android.widget.Toast.LENGTH_SHORT).show() + + java.util.concurrent.Executors.newSingleThreadExecutor().execute { + try { + val urlStr = "https://github.com/LeanBitLab/Leantype-Handwriting-Plugin/releases/latest/download/handwriting_plugin.apk" + var url = java.net.URL(urlStr) + var conn = url.openConnection() as java.net.HttpURLConnection + conn.instanceFollowRedirects = true + conn.setRequestProperty("User-Agent", "HeliboardL") + conn.connect() + + var redirectConn = conn + var status = redirectConn.responseCode + var redirectCount = 0 + while ((status == java.net.HttpURLConnection.HTTP_MOVED_TEMP || status == java.net.HttpURLConnection.HTTP_MOVED_PERM || status == java.net.HttpURLConnection.HTTP_SEE_OTHER) && redirectCount < 5) { + val newUrl = redirectConn.getHeaderField("Location") + redirectConn.disconnect() + val nextUrl = java.net.URL(newUrl) + redirectConn = nextUrl.openConnection() as java.net.HttpURLConnection + redirectConn.setRequestProperty("User-Agent", "HeliboardL") + redirectConn.connect() + status = redirectConn.responseCode + redirectCount++ + } + + if (status != java.net.HttpURLConnection.HTTP_OK) { + throw java.io.IOException("Server returned HTTP $status") + } + + val tempFile = java.io.File(context.cacheDir, "temp_handwriting_plugin.apk") + redirectConn.inputStream.use { input -> + java.io.FileOutputStream(tempFile).use { output -> + input.copyTo(output) + } + } + redirectConn.disconnect() + + val success = HandwritingLoader.importPlugin(context, android.net.Uri.fromFile(tempFile)) + tempFile.delete() + + android.os.Handler(android.os.Looper.getMainLooper()).post { + button.isEnabled = true + if (success) { + button.text = "Success" + android.widget.Toast.makeText(context, "Handwriting plugin installed!", android.widget.Toast.LENGTH_SHORT).show() + val overlay = findViewById(R.id.handwriting_plugin_overlay) + overlay?.visibility = View.GONE + editorInfo?.let { ei -> + keyboardActionListener?.let { listener -> + startHandwriting(ei, listener, currentLanguage) + } + } + } else { + button.text = "Download Plugin" + android.widget.Toast.makeText(context, "Failed to install plugin", android.widget.Toast.LENGTH_LONG).show() + } + } + } catch (e: Exception) { + Log.e("HandwritingView", "Failed to download plugin", e) + android.os.Handler(android.os.Looper.getMainLooper()).post { + button.isEnabled = true + button.text = "Download Plugin" + android.widget.Toast.makeText(context, "Download failed: ${e.localizedMessage}", android.widget.Toast.LENGTH_LONG).show() + } + } + } + } } 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 79c939f1b..3ea61ce4d 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -564,15 +564,6 @@ public boolean onUpdateSelection(final int oldSelStart, final int oldSelEnd, fin } } - if (oldSelStart != newSelStart || oldSelEnd != newSelEnd) { - if (newSelStart != mLastExpandedCursorPosition) { - mLastExpandedText = null; - mLastShortcutText = null; - mLastExpandedCursorPosition = -1; - mLastExpandedCursorOffset = -1; - } - } - final boolean selectionChangedOrSafeToReset = oldSelStart != newSelStart || oldSelEnd != newSelEnd // selection // changed || !mWordComposer.isComposingWord(); // safe to reset @@ -2506,28 +2497,6 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu mSpaceState = SpaceState.NONE; mDeleteCount++; - if (mLastExpandedText != null && !event.isKeyRepeat()) { - final int expectedCursor = mConnection.getExpectedSelectionEnd(); - if (expectedCursor == mLastExpandedCursorPosition) { - final int beforeLen = mLastExpandedCursorOffset; - final int afterLen = mLastExpandedText.length() - beforeLen; - final CharSequence textBefore = mConnection.getTextBeforeCursor(beforeLen, 0); - final CharSequence textAfter = mConnection.getTextAfterCursor(afterLen, 0); - final String expectedBefore = mLastExpandedText.substring(0, beforeLen); - final String expectedAfter = mLastExpandedText.substring(beforeLen); - if (textBefore != null && textBefore.toString().equals(expectedBefore) - && textAfter != null && textAfter.toString().equals(expectedAfter)) { - mConnection.deleteSurroundingText(beforeLen, afterLen); - mConnection.commitText(mLastShortcutText, 1); - mLastExpandedText = null; - mLastShortcutText = null; - mLastExpandedCursorPosition = -1; - mLastExpandedCursorOffset = -1; - return; - } - } - } - if (mLastExpandedText != null && !event.isKeyRepeat()) { final int expectedCursor = mConnection.getExpectedSelectionEnd(); if (expectedCursor == mLastExpandedCursorPosition) { diff --git a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt index db2701b7b..de0e41660 100644 --- a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt +++ b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt @@ -66,6 +66,10 @@ import helium314.keyboard.latin.utils.removeFirst import helium314.keyboard.latin.utils.removePinnedKey import helium314.keyboard.latin.utils.setToolbarButtonsActivatedState import helium314.keyboard.latin.utils.setToolbarButtonsActivatedStateOnPrefChange +import helium314.keyboard.latin.utils.isMainDictionaryMissing +import helium314.keyboard.latin.utils.showMissingDictionaryComposeDialog +import helium314.keyboard.latin.utils.SubtypeSettings +import helium314.keyboard.latin.utils.locale import helium314.keyboard.settings.SettingsWithoutKey import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.abs @@ -128,6 +132,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) private val pinnedKeys: ViewGroup = findViewById(R.id.pinned_keys) private val suggestionsStrip: ViewGroup = findViewById(R.id.suggestions_strip) private val toolbarExpandKey = findViewById(R.id.suggestions_strip_toolbar_key) + private var dictDownloadButton: ImageButton? = null private val toolbarArrowIcon = KeyboardIconsSet.instance.getNewDrawable(KeyboardIconsSet.NAME_TOOLBAR_KEY, context) private val defaultToolbarBackground: Drawable = toolbarExpandKey.background private val enabledToolKeyBackground = GradientDrawable() @@ -165,7 +170,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) } else { isToolbarManuallyOpen = settingsValues.mAutoShowToolbar } - + val colors = settingsValues.mColors // expand key @@ -198,12 +203,12 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) if (Settings.getValues().mSplitToolbar) { val stripHeight = resources.getDimensionPixelSize(R.dimen.config_suggestions_strip_height) - + val wrapper = findViewById(R.id.suggestions_strip_wrapper) - + // Set wrapper to vertical wrapper.orientation = LinearLayout.VERTICAL - + // Create toolbar row for Expand Key, Toolbar, Pinned Keys val toolbarRow = LinearLayout(context) toolbarRow.orientation = LinearLayout.HORIZONTAL @@ -211,40 +216,40 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) LinearLayout.LayoutParams.MATCH_PARENT, stripHeight ) - + // Remove views from wrapper wrapper.removeView(toolbarExpandKey) wrapper.removeView(toolbarContainer) wrapper.removeView(pinnedKeys) - + // Set new layout params when adding to toolbarRow val expandKeyParams = LinearLayout.LayoutParams( toolbarExpandKey.layoutParams.width, LinearLayout.LayoutParams.MATCH_PARENT ) toolbarExpandKey.layoutParams = expandKeyParams - + val toolbarParams = LinearLayout.LayoutParams( 0, LinearLayout.LayoutParams.MATCH_PARENT, 1f // weight ) toolbarContainer.layoutParams = toolbarParams - + val pinnedParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT ) pinnedKeys.layoutParams = pinnedParams - + // Add views to toolbar row toolbarRow.addView(toolbarExpandKey) toolbarRow.addView(toolbarContainer) toolbarRow.addView(pinnedKeys) - + // Add toolbar row to wrapper at the START (Top) - Toolbar at top, Suggestions at bottom wrapper.addView(toolbarRow, 0) - + // Set suggestions strip params - use weight to fill remaining space val suggestionsParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, @@ -265,7 +270,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val stripHeight = resources.getDimensionPixelSize(R.dimen.config_suggestions_strip_height) val split = Settings.getValues().mSplitToolbar - + val newHeightSpec = if (split) { MeasureSpec.makeMeasureSpec(stripHeight * 2, MeasureSpec.EXACTLY) } else { @@ -443,7 +448,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) fun showLoadingAnimation() { if (isLoadingAnimationActive) return isLoadingAnimationActive = true - + // Set loading border on the whole toolbar view this.foreground = loadingBorderDrawable @@ -457,10 +462,10 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) Settings.getValues().mColors.setColor(proofreadKey, ColorType.TOOL_BAR_KEY) } } - + // Get accent color from theme (GESTURE_TRAIL is the accent color) - val accentColor = Settings.getValues().mColors.get(ColorType.GESTURE_TRAIL) - + val accentColor = Settings.getValues().mColors.get(ColorType.GESTURE_TRAIL) + // Create pulse animation loadingAnimator = ValueAnimator.ofFloat(0.25f, 1f).apply { duration = 800 @@ -481,7 +486,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) fun hideLoadingAnimation() { if (!isLoadingAnimationActive) return isLoadingAnimationActive = false - + loadingAnimator?.cancel() loadingAnimator = null loadingBorderDrawable.setStroke(4, Color.TRANSPARENT) @@ -504,10 +509,10 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String?) { setToolbarButtonsActivatedStateOnPrefChange(pinnedKeys, key) setToolbarButtonsActivatedStateOnPrefChange(toolbar, key) - if (key == Settings.PREF_PINNED_TOOLBAR_KEYS - || key == Settings.PREF_TOOLBAR_KEYS - || key == Settings.PREF_QUICK_PIN_TOOLBAR_KEYS - || key == Settings.PREF_AUTO_HIDE_PINNED_KEYS + if (key == Settings.PREF_PINNED_TOOLBAR_KEYS + || key == Settings.PREF_TOOLBAR_KEYS + || key == Settings.PREF_QUICK_PIN_TOOLBAR_KEYS + || key == Settings.PREF_AUTO_HIDE_PINNED_KEYS || key == Settings.PREF_SPLIT_TOOLBAR || key == "pref_custom_ai_show_tags_on_toolbar" || key?.startsWith("pref_custom_ai_tag_") == true) { @@ -550,7 +555,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) if (isExternalSuggestionVisible) { return false } - + // In split mode, don't intercept touches on the top row (toolbar row) // to prevent accidentally cancelling long presses on toolbar buttons. if (Settings.getValues().mSplitToolbar) { @@ -838,7 +843,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT ).apply { gravity = android.view.Gravity.CENTER_VERTICAL } - + button.setOnClickListener { // Set the selected language and start translation context.prefs().edit().apply { @@ -902,7 +907,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) val hideToolbarKeys = isDeviceLocked(context) // Keep click listener active in split mode (though key is hidden, better to leave logic clean) toolbarExpandKey.setOnClickListener(if (hideToolbarKeys || !toolbarIsExpandable) null else this) - + if (split) { toolbarExpandKey.isVisible = false pinnedKeys.isVisible = false // Hide pinned keys completely in split mode @@ -918,6 +923,48 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) // This prevents conflicts with auto-hide pinned keys logic layoutHelper.setSuggestionsCountInStrip(3) } + + // ponytail: show/hide dictionary download button if dictionary is missing + if (helium314.keyboard.latin.BuildConfig.FLAVOR == "standard") { + val currentLocale = SubtypeSettings.getSelectedSubtype(context.prefs()).locale() + if (isMainDictionaryMissing(context, currentLocale) && !hideToolbarKeys) { + if (dictDownloadButton == null) { + dictDownloadButton = ImageButton(context, null, R.attr.suggestionWordStyle).apply { + scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE + val padding = 6.dpToPx(resources) + setPadding(padding, padding, padding, padding) + setImageResource(R.drawable.ic_dictionary) + contentDescription = context.getString(R.string.download) + setOnClickListener { + val token = this.windowToken + if (token != null) { + showMissingDictionaryComposeDialog(context, currentLocale, token) { + updateKeys() + } + } + } + } + val toolbarHeight = min(toolbarExpandKey.layoutParams.height, resources.getDimension(R.dimen.config_suggestions_strip_height).toInt()) + dictDownloadButton?.layoutParams = LinearLayout.LayoutParams(toolbarHeight, toolbarHeight).apply { + gravity = android.view.Gravity.CENTER_VERTICAL + } + + val wrapper = findViewById(R.id.suggestions_strip_wrapper) + val expandIndex = wrapper.indexOfChild(toolbarExpandKey) + wrapper.addView(dictDownloadButton, expandIndex + 1) + } + val colors = Settings.getValues().mColors + colors.setColor(dictDownloadButton!!, ColorType.TOOL_BAR_KEY) + dictDownloadButton?.setBackgroundResource(R.drawable.toolbar_key_background) + colors.setColor(dictDownloadButton!!.background, ColorType.TOOL_BAR_EXPAND_KEY_BACKGROUND) + dictDownloadButton?.isVisible = true + } else { + dictDownloadButton?.isVisible = false + } + } else { + dictDownloadButton?.isVisible = false + } + isExternalSuggestionVisible = false } @@ -951,7 +998,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) val pinnedKeysList = getPinnedToolbarKeys(context.prefs()) val mToolbarMode = Settings.getValues().mToolbarMode val isSplitToolbar = Settings.getValues().mSplitToolbar - + // Toolbar keys setup // Always populate toolbar keys if mode allows, visibility handled in updateKeys if (mToolbarMode == ToolbarMode.TOOLBAR_KEYS || mToolbarMode == ToolbarMode.EXPANDABLE) { @@ -969,7 +1016,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) toolbar.addView(button) } } - + // Only draw pinned keys if not in split mode if (!isSplitToolbar && !Settings.getValues().mSuggestionStripHiddenPerUserSettings) { for (pinnedKey in pinnedKeysList) { @@ -997,10 +1044,10 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) return } suggestionsStrip.isVisible = true - + val PLACEHOLDER_TAG = "PLACEHOLDER_VIEW" val placeholder = suggestionsStrip.findViewWithTag(PLACEHOLDER_TAG) - + // Check if there are any visible suggestions with actual text content var hasRealSuggestions = false for (i in 0 until suggestionsStrip.childCount) { @@ -1021,20 +1068,20 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) placeholderContainer.tag = PLACEHOLDER_TAG placeholderContainer.orientation = LinearLayout.HORIZONTAL placeholderContainer.layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT ) - + // Random suggestion words to display val randomSuggestions = listOf( "the", "and", "for", "you", "with", "have", "this", "from", "will", "can", "hello", "thanks", "please", "okay", "good" ).shuffled().take(5) - + val colors = Settings.getValues().mColors val customTypeface = Settings.getInstance().customTypeface - + randomSuggestions.forEach { word -> val suggestionView = TextView(context, null, R.attr.suggestionWordStyle) suggestionView.text = word @@ -1044,7 +1091,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) suggestionView.typeface = customTypeface colors.setBackground(suggestionView, ColorType.STRIP_BACKGROUND) suggestionView.setTextColor(colors.get(ColorType.KEY_TEXT)) - + val params = LinearLayout.LayoutParams( 0, LinearLayout.LayoutParams.MATCH_PARENT, @@ -1053,7 +1100,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) suggestionView.layoutParams = params placeholderContainer.addView(suggestionView) } - + suggestionsStrip.addView(placeholderContainer) } } @@ -1098,11 +1145,11 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) if (customTypeface != null) emojiView.typeface = customTypeface emojiView.gravity = android.view.Gravity.CENTER emojiView.setPadding( - 8.dpToPx(resources), 2.dpToPx(resources), + 8.dpToPx(resources), 2.dpToPx(resources), 8.dpToPx(resources), 2.dpToPx(resources) ) emojiView.layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT ) emojiView.setOnClickListener { @@ -1118,6 +1165,32 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) suggestionsStrip.isVisible = true } + /** + * ponytail: Shows a download button in the suggestion strip (used in split toolbar mode). + * @param onClick Callback when the download button is tapped + * @param isDownloading Whether the dictionary is currently downloading + */ + fun setEmojiDownloadButton(onClick: java.lang.Runnable, isDownloading: Boolean) { + if (!Settings.getValues().mSplitToolbar) return + isShowingEmojiSuggestions = true + suggestionsStrip.removeAllViews() + + val btn = android.widget.Button(context) + btn.text = if (isDownloading) "Downloading..." else "Download Dictionary" + btn.textSize = 12f + btn.isAllCaps = false + btn.isEnabled = !isDownloading + btn.layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + btn.setOnClickListener { + onClick.run() + } + suggestionsStrip.addView(btn) + suggestionsStrip.isVisible = true + } + /** * Clears emoji suggestions and restores normal suggestion strip state. */ diff --git a/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt index 9e19a0ac6..667a12c84 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt @@ -3,12 +3,25 @@ package helium314.keyboard.latin.utils import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp import androidx.core.content.edit +import android.os.IBinder import helium314.keyboard.compat.locale import helium314.keyboard.latin.R import helium314.keyboard.latin.common.Links @@ -16,16 +29,33 @@ import helium314.keyboard.latin.common.LocaleUtils import helium314.keyboard.latin.common.LocaleUtils.constructLocale import helium314.keyboard.latin.settings.Defaults import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.settings.Theme import helium314.keyboard.settings.dialogs.ConfirmationDialog +import helium314.keyboard.settings.dialogs.ConfirmationDialogContent import java.io.File import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner fun getDictionaryLocales(context: Context): MutableSet { val locales = HashSet() - // get cached dictionaries: extracted or user-added dictionaries + // ponytail: get cached dictionaries: extracted or user-added/downloaded dictionaries DictionaryInfoUtils.getCacheDirectories(context).forEach { directory -> - if (!hasAnythingOtherThanExtractedMainDictionary(directory)) return@forEach + if (!hasAnythingOtherThanExtractedMainDictionary(context, directory)) return@forEach val locale = DictionaryInfoUtils.getWordListIdFromFileName(directory.name).constructLocale() locales.add(locale) } @@ -36,12 +66,19 @@ fun getDictionaryLocales(context: Context): MutableSet { locales.add(DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(dictionary)) } } + // ponytail: include enabled locales and multilingual secondary locales + val enabled = SubtypeSettings.getEnabledSubtypes() + enabled.forEach { subtype -> + locales.add(subtype.locale()) + getSecondaryLocales(subtype.extraValue).forEach { locales.add(it) } + } return locales } @Composable -fun MissingDictionaryDialog(onDismissRequest: () -> Unit, locale: Locale) { - val prefs = LocalContext.current.prefs() +fun MissingDictionaryDialog(onDismissRequest: () -> Unit, locale: Locale, inline: Boolean = false) { + val context = LocalContext.current + val prefs = context.prefs() if (prefs.getBoolean(Settings.PREF_DONT_SHOW_MISSING_DICTIONARY_DIALOG, Defaults.PREF_DONT_SHOW_MISSING_DICTIONARY_DIALOG)) { onDismissRequest() return @@ -52,16 +89,52 @@ fun MissingDictionaryDialog(onDismissRequest: () -> Unit, locale: Locale) { val dictionaryLink = stringResource(R.string.dictionary_link_text).withHtmlLink(dictUrl) val message = stringResource(R.string.no_dictionary_message, repositoryLink, locale.toString(), dictionaryLink) var annotatedString = message.htmlToAnnotated() - if (availableDicts.isNotEmpty()) + // ponytail: in standard flavor, if there are known dicts we show them as downloadable rows instead of bullet links + val knownDicts = remember { + if (helium314.keyboard.latin.BuildConfig.FLAVOR == "standard") { + getKnownDictionariesForLocale(locale, context) + } else emptyList() + } + if (availableDicts.isNotEmpty() && knownDicts.isEmpty()) annotatedString += AnnotatedString("\n") + availableDicts - ConfirmationDialog( - onDismissRequest = onDismissRequest, - cancelButtonText = stringResource(R.string.dialog_close), - onConfirmed = { prefs.edit { putBoolean(Settings.PREF_DONT_SHOW_MISSING_DICTIONARY_DIALOG, true) } }, - confirmButtonText = stringResource(R.string.no_dictionary_dont_show_again_button), - content = { Text(annotatedString) } - ) + if (inline) { + ConfirmationDialogContent( + onDismissRequest = onDismissRequest, + cancelButtonText = stringResource(R.string.dialog_close), + onConfirmed = { prefs.edit { putBoolean(Settings.PREF_DONT_SHOW_MISSING_DICTIONARY_DIALOG, true) } }, + confirmButtonText = stringResource(R.string.no_dictionary_dont_show_again_button), + content = { + androidx.compose.foundation.layout.Column { + Text(annotatedString) + if (knownDicts.isNotEmpty()) { + androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + knownDicts.forEach { (desc, link) -> + DownloadableDictionaryRow(locale = locale, desc = desc, link = link, onRefresh = {}) + } + } + } + } + ) + } else { + ConfirmationDialog( + onDismissRequest = onDismissRequest, + cancelButtonText = stringResource(R.string.dialog_close), + onConfirmed = { prefs.edit { putBoolean(Settings.PREF_DONT_SHOW_MISSING_DICTIONARY_DIALOG, true) } }, + confirmButtonText = stringResource(R.string.no_dictionary_dont_show_again_button), + content = { + androidx.compose.foundation.layout.Column { + Text(annotatedString) + if (knownDicts.isNotEmpty()) { + androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + knownDicts.forEach { (desc, link) -> + DownloadableDictionaryRow(locale = locale, desc = desc, link = link, onRefresh = {}) + } + } + } + } + ) + } } /** if dictionaries for [locale] or language are available returns links to them */ @@ -116,11 +189,182 @@ fun cleanUnusedMainDicts(context: Context) { for (dir in dirs) { if (!dir.isDirectory) continue if (dir.name in usedLocaleLanguageTags) continue - if (hasAnythingOtherThanExtractedMainDictionary(dir)) + if (hasAnythingOtherThanExtractedMainDictionary(context, dir)) continue dir.deleteRecursively() } } -private fun hasAnythingOtherThanExtractedMainDictionary(dir: File) = - dir.listFiles()?.any { it.name != DictionaryInfoUtils.MAIN_DICT_FILE_NAME } != false +// ponytail: check if the cached folder contains user-added or downloaded dicts (which shouldn't be automatically deleted or hidden) +private fun hasAnythingOtherThanExtractedMainDictionary(context: Context, dir: File): Boolean { + val files = dir.listFiles() ?: return false + if (files.isEmpty()) return false + if (files.any { it.name != DictionaryInfoUtils.MAIN_DICT_FILE_NAME }) return true + if (files.any { it.name == DictionaryInfoUtils.MAIN_DICT_FILE_NAME }) { + val locale = DictionaryInfoUtils.getWordListIdFromFileName(dir.name).constructLocale() + val assetsList = DictionaryInfoUtils.getAssetsDictionaryList(context) ?: return true + val best = LocaleUtils.getBestMatch(locale, assetsList.toList()) { + DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(it) + } + return best == null + } + return false +} + +// ponytail: Dynamic dictionary downloader using HTTP URL connection. +fun downloadDictionary(context: Context, locale: Locale, type: String, linkUrl: String, onComplete: (Boolean) -> Unit) { + val cacheDir = DictionaryInfoUtils.getCacheDirectoryForLocale(locale, context) ?: return onComplete(false) + val targetFile = File(cacheDir, "${type}.dict") + CoroutineScope(Dispatchers.IO).launch { + var success = false + try { + java.net.URL(linkUrl).openStream().use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) + } + } + success = true + } catch (e: Exception) { + Log.e("DictionaryUtils", "Failed to download dictionary", e) + } + withContext(Dispatchers.Main) { + onComplete(success) + } + } +} + +@Composable +fun DownloadableDictionaryRow(locale: Locale, desc: String, link: String, onRefresh: () -> Unit) { + val ctx = LocalContext.current + val type = remember(link) { link.substringAfterLast("/").substringBefore("_") } + val cacheDir = remember(locale) { DictionaryInfoUtils.getCacheDirectoryForLocale(locale, ctx) } + val file = remember(cacheDir, type) { cacheDir?.let { File(it, "$type.dict") } } + var downloading by remember { mutableStateOf(false) } + var exists by remember(file) { mutableStateOf(file?.exists() == true) } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) { + Text(desc, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + if (exists) { + var showDeleteDialog by remember { mutableStateOf(false) } + androidx.compose.material3.TextButton(onClick = { showDeleteDialog = true }) { + Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error) + } + if (showDeleteDialog) { + ConfirmationDialog( + onDismissRequest = { showDeleteDialog = false }, + confirmButtonText = stringResource(R.string.remove), + onConfirmed = { + file?.delete() + exists = false + onRefresh() + }, + content = { Text(stringResource(R.string.remove_dictionary_message, type)) } + ) + } + } else if (downloading) { + Text( + stringResource(R.string.downloading), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(end = 8.dp) + ) + } else { + androidx.compose.material3.TextButton(onClick = { + downloading = true + downloadDictionary(ctx, locale, type, link) { success -> + downloading = false + if (success) { + exists = true + onRefresh() + } else { + android.widget.Toast.makeText(ctx, ctx.getString(R.string.download_failed), android.widget.Toast.LENGTH_SHORT).show() + } + } + }) { + Text(stringResource(R.string.download)) + } + } + } +} + +// ponytail: check if the main dictionary is missing/not loaded for a given locale +fun isMainDictionaryMissing(context: Context, locale: Locale): Boolean { + // 1. check if there's any dictionary in assets matching the locale + val assetsList = DictionaryInfoUtils.getAssetsDictionaryList(context) + if (assetsList != null) { + val best = LocaleUtils.getBestMatch(locale, assetsList.toList()) { + DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(it) + } + if (best != null) return false + } + // 2. check if cache directory has a main.dict file + val cacheDir = DictionaryInfoUtils.getCacheDirectoryForLocale(locale, context)?.let { File(it) } + if (cacheDir?.exists() == true && cacheDir.isDirectory) { + val hasMain = cacheDir.listFiles()?.any { it.name == "main.dict" } == true + if (hasMain) return false + } + // 3. check if there is a known downloadable main dictionary for this locale + val known = getKnownDictionariesForLocale(locale, context) + return known.any { (_, link) -> link.substringAfterLast("/").substringBefore("_") == "main" } +} + +// ponytail: helper to host ComposeView in non-Activity window context (e.g. IME Service) +private class ServiceLifecycleOwner : LifecycleOwner, SavedStateRegistryOwner, ViewModelStoreOwner { + private val lifecycleRegistry = LifecycleRegistry(this) + private val savedStateRegistryController = SavedStateRegistryController.create(this) + private val store = ViewModelStore() + + init { + savedStateRegistryController.performRestore(null) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + override val lifecycle: Lifecycle get() = lifecycleRegistry + override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry + override val viewModelStore: ViewModelStore get() = store + + fun destroy() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + store.clear() + } +} + +// ponytail: bridge compose dialog to legacy view +fun showMissingDictionaryComposeDialog(context: Context, locale: Locale, windowToken: IBinder, onDismiss: () -> Unit) { + val dialog = android.app.Dialog(getPlatformDialogThemeContext(context)) + val lifecycleOwner = ServiceLifecycleOwner() + val composeView = androidx.compose.ui.platform.ComposeView(context).apply { + setViewTreeLifecycleOwner(lifecycleOwner) + setViewTreeSavedStateRegistryOwner(lifecycleOwner) + setViewTreeViewModelStoreOwner(lifecycleOwner) + setContent { + Theme { + MissingDictionaryDialog( + onDismissRequest = { + dialog.dismiss() + onDismiss() + }, + locale = locale, + inline = true + ) + } + } + } + dialog.setOnDismissListener { + lifecycleOwner.destroy() + } + dialog.setContentView(composeView) + val window = dialog.window + val layoutParams = window?.attributes + layoutParams?.token = windowToken + layoutParams?.type = android.view.WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG + window?.attributes = layoutParams + dialog.show() +} diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt index 2d38093cb..54ed101bf 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt @@ -353,19 +353,26 @@ enum class ToolbarMode { val toolbarKeyStrings = entries.associateWithTo(EnumMap(ToolbarKey::class.java)) { it.toString().lowercase(Locale.US) } -private val excludedKeys by lazy { - val customAiKeys = if (BuildConfig.FLAVOR != "standard" && BuildConfig.FLAVOR != "standardOptimised" && BuildConfig.FLAVOR != "offline") +// ponytail: Split excluded keys into flavor-specific exclusions and main-toolbar-only exclusions to allow clipboard toolbar to render clipboard search and close history. +private val flavorExcludedKeys by lazy { + val customAiKeys = if (BuildConfig.FLAVOR != "standard" && BuildConfig.FLAVOR != "offline") ToolbarKey.entries.filter { it.name.startsWith("CUSTOM_AI_") } else emptyList() val otherKeys = if (BuildConfig.FLAVOR == "offlinelite") - listOf(CLOSE_HISTORY, PROOFREAD, TRANSLATE, CLIPBOARD_SEARCH, HANDWRITING) + listOf(PROOFREAD, TRANSLATE, CLIPBOARD_SEARCH, HANDWRITING) else if (BuildConfig.FLAVOR == "offline") - listOf(CLOSE_HISTORY, CLIPBOARD_SEARCH, HANDWRITING) + listOf(HANDWRITING) else - listOf(CLOSE_HISTORY, CLIPBOARD_SEARCH) + emptyList() customAiKeys + otherKeys } +private val mainToolbarExcludedKeys = listOf(CLOSE_HISTORY, CLIPBOARD_SEARCH) + +private val excludedKeys by lazy { + flavorExcludedKeys + mainToolbarExcludedKeys +} + val defaultToolbarPref by lazy { val default = when (helium314.keyboard.latin.BuildConfig.FLAVOR) { "offline" -> listOf(SETTINGS, VOICE, CLIPBOARD, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, UNDO, INCOGNITO, COPY, PASTE, PROOFREAD, TRANSLATE) @@ -432,7 +439,7 @@ fun getEnabledToolbarKeys(prefs: SharedPreferences) = getEnabledToolbarKeys(pref fun getPinnedToolbarKeys(prefs: SharedPreferences) = getEnabledToolbarKeys(prefs, Settings.PREF_PINNED_TOOLBAR_KEYS, defaultPinnedToolbarPref) -fun getEnabledClipboardToolbarKeys(prefs: SharedPreferences) = getEnabledToolbarKeys(prefs, Settings.PREF_CLIPBOARD_TOOLBAR_KEYS, defaultClipboardToolbarPref) +fun getEnabledClipboardToolbarKeys(prefs: SharedPreferences) = getEnabledToolbarKeys(prefs, Settings.PREF_CLIPBOARD_TOOLBAR_KEYS, defaultClipboardToolbarPref, flavorExcludedKeys) fun addPinnedKey(prefs: SharedPreferences, key: ToolbarKey) { // remove the existing version of this key and add the enabled one after the last currently enabled key @@ -455,14 +462,14 @@ fun removePinnedKey(prefs: SharedPreferences, key: ToolbarKey) { prefs.edit { putString(Settings.PREF_PINNED_TOOLBAR_KEYS, result) } } -private fun getEnabledToolbarKeys(prefs: SharedPreferences, pref: String, default: String): List { +private fun getEnabledToolbarKeys(prefs: SharedPreferences, pref: String, default: String, exclusions: Collection = excludedKeys): List { val string = prefs.getString(pref, default)!! return string.split(Separators.ENTRY).mapNotNull { val split = it.split(Separators.KV) if (split.last() == "true") { try { val key = ToolbarKey.valueOf(split.first()) - if (key in excludedKeys) null else key + if (key in exclusions) null else key } catch (_: IllegalArgumentException) { null } diff --git a/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt b/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt index 29e98b232..a38dc070b 100644 --- a/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt +++ b/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt @@ -266,7 +266,7 @@ fun WelcomeWizard( { step++ }, { step-- } ) { - if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised") { + if (BuildConfig.FLAVOR == "standard") { val service = remember { helium314.keyboard.latin.utils.ProofreadService(ctx) } var currentProvider by remember { mutableStateOf(service.getProvider()) } val aiConfigured = when (currentProvider) { diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/ConfirmationDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/ConfirmationDialog.kt index e8ac238ba..8761f760a 100644 --- a/app/src/main/java/helium314/keyboard/settings/dialogs/ConfirmationDialog.kt +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/ConfirmationDialog.kt @@ -13,6 +13,31 @@ import helium314.keyboard.settings.previewDark // taken from StreetComplete /** Slight specialization of an alert dialog: AlertDialog with OK and Cancel button. Both buttons * call [onDismissRequest] and the OK button additionally calls [onConfirmed]. */ +@Composable +fun ConfirmationDialogContent( + onDismissRequest: () -> Unit, + onConfirmed: () -> Unit, + modifier: Modifier = Modifier, + title: @Composable (() -> Unit)? = null, + content: @Composable (() -> Unit)? = null, + confirmButtonText: String = stringResource(android.R.string.ok), + cancelButtonText: String = stringResource(android.R.string.cancel), + neutralButtonText: String? = null, + onNeutral: () -> Unit = { }, +) { + ThreeButtonAlertDialogContent( + onDismissRequest = onDismissRequest, + onConfirmed = onConfirmed, + confirmButtonText = confirmButtonText, + cancelButtonText = cancelButtonText, + neutralButtonText = neutralButtonText, + onNeutral = onNeutral, + modifier = modifier, + title = title, + content = content, + ) +} + @Composable fun ConfirmationDialog( onDismissRequest: () -> Unit, diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt index 1aa928a38..c4219624a 100644 --- a/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt @@ -2,6 +2,7 @@ package helium314.keyboard.settings.dialogs import android.content.Intent +import android.content.Context import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -35,6 +36,7 @@ import helium314.keyboard.latin.common.LocaleUtils.localizedDisplayName import helium314.keyboard.latin.utils.DictionaryInfoUtils import helium314.keyboard.latin.utils.prefs import helium314.keyboard.latin.utils.createDictionaryTextAnnotated +import helium314.keyboard.latin.utils.DownloadableDictionaryRow import helium314.keyboard.settings.DeleteButton import helium314.keyboard.settings.ExpandButton import helium314.keyboard.settings.Theme @@ -45,6 +47,10 @@ import java.io.File import java.util.Locale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalResources +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable fun DictionaryDialog( @@ -52,7 +58,8 @@ fun DictionaryDialog( locale: Locale, ) { val ctx = LocalContext.current - val (dictionaries, hasInternal) = getUserAndInternalDictionaries(ctx, locale) + var refreshTrigger by remember { mutableStateOf(0) } + val (dictionaries, hasInternal) = remember(refreshTrigger) { getUserAndInternalDictionaries(ctx, locale) } val mainDict = dictionaries.firstOrNull { it.name == Dictionary.TYPE_MAIN + "_" + DictionaryInfoUtils.USER_DICTIONARY_SUFFIX } val addonDicts = dictionaries.filterNot { it == mainDict } val picker = dictionaryFilePicker(locale) @@ -72,7 +79,7 @@ fun DictionaryDialog( } } val internalId = best?.let { "main:" + it.substringAfter("_").substringBefore(".") } - + val color = if (mainDict == null) MaterialTheme.typography.titleSmall.color else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) // for disabled look val bottomPadding = if (mainDict == null) 12.dp else 0.dp @@ -112,23 +119,41 @@ fun DictionaryDialog( } } if (mainDict != null) - DictionaryDetails(mainDict) + DictionaryDetails(mainDict) { refreshTrigger++ } if (addonDicts.isNotEmpty()) { HorizontalDivider() Text(stringResource(R.string.dictionary_category_title), modifier = Modifier.padding(vertical = 12.dp), style = MaterialTheme.typography.titleSmall ) - addonDicts.forEach { DictionaryDetails(it) } + addonDicts.forEach { DictionaryDetails(it) { refreshTrigger++ } } + } + val knownDicts = remember { + if (helium314.keyboard.latin.BuildConfig.FLAVOR == "standard") { + helium314.keyboard.latin.utils.getKnownDictionariesForLocale(locale, ctx) + } else emptyList() } - val dictString = createDictionaryTextAnnotated(locale) - if (dictString.isNotEmpty()) { + if (knownDicts.isNotEmpty()) { HorizontalDivider() Text(stringResource(R.string.dictionary_available), modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), style = MaterialTheme.typography.titleSmall ) - Text(dictString, style = LocalTextStyle.current.merge(lineHeight = 1.8.em)) + knownDicts.forEach { (desc, link) -> + DownloadableDictionaryRow(locale, desc, link) { + refreshTrigger++ + } + } + } else { + val dictString = createDictionaryTextAnnotated(locale) + if (dictString.isNotEmpty()) { + HorizontalDivider() + Text(stringResource(R.string.dictionary_available), + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + style = MaterialTheme.typography.titleSmall + ) + Text(dictString, style = LocalTextStyle.current.merge(lineHeight = 1.8.em)) + } } } }, @@ -144,7 +169,7 @@ fun DictionaryDialog( } @Composable -private fun DictionaryDetails(dict: File) { +private fun DictionaryDetails(dict: File, onDelete: () -> Unit) { val header = DictionaryInfoUtils.getDictionaryFileHeaderOrNull(dict) ?: return val type = header.mIdString.substringBefore(":") var showDeleteDialog by remember { mutableStateOf(false) } @@ -185,11 +210,15 @@ private fun DictionaryDetails(dict: File) { ConfirmationDialog( onDismissRequest = { showDeleteDialog = false }, confirmButtonText = stringResource(R.string.remove), - onConfirmed = { dict.delete() }, + onConfirmed = { + dict.delete() + onDelete() + }, content = { Text(stringResource(R.string.remove_dictionary_message, type))} ) } + @Preview @Composable private fun Preview() { diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/ThreeButtonAlertDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/ThreeButtonAlertDialog.kt index 22a465166..3d2513796 100644 --- a/app/src/main/java/helium314/keyboard/settings/dialogs/ThreeButtonAlertDialog.kt +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/ThreeButtonAlertDialog.kt @@ -32,7 +32,7 @@ import helium314.keyboard.settings.Theme import helium314.keyboard.settings.previewDark @Composable -fun ThreeButtonAlertDialog( +fun ThreeButtonAlertDialogContent( onDismissRequest: () -> Unit, onConfirmed: () -> Unit, modifier: Modifier = Modifier, @@ -45,83 +45,114 @@ fun ThreeButtonAlertDialog( confirmButtonText: String? = stringResource(android.R.string.ok), cancelButtonText: String = stringResource(android.R.string.cancel), neutralButtonText: String? = null, - reducePadding: Boolean = false, - properties: DialogProperties = DialogProperties() ) { - Dialog( - onDismissRequest = onDismissRequest, - properties = properties + Box( + modifier = modifier.widthIn(min = 280.dp, max = 560.dp), + propagateMinConstraints = true ) { - Box( - modifier = modifier.widthIn(min = 280.dp, max = 560.dp), - propagateMinConstraints = true + Surface( + shape = androidx.compose.foundation.shape.RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + contentColor = contentColorFor(MaterialTheme.colorScheme.surface), + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) ) { - Surface( - shape = androidx.compose.foundation.shape.RoundedCornerShape(28.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 6.dp, - contentColor = contentColorFor(MaterialTheme.colorScheme.surface), - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) - ) { - Column(modifier = Modifier.padding(24.dp)) { - icon?.let { - Box( - Modifier - .padding(bottom = 16.dp) - .align(androidx.compose.ui.Alignment.CenterHorizontally) - ) { - CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides MaterialTheme.colorScheme.primary) { - icon() - } + Column(modifier = Modifier.padding(24.dp)) { + icon?.let { + Box( + Modifier + .padding(bottom = 16.dp) + .align(androidx.compose.ui.Alignment.CenterHorizontally) + ) { + CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides MaterialTheme.colorScheme.primary) { + icon() } } - title?.let { - CompositionLocalProvider( - LocalTextStyle provides MaterialTheme.typography.headlineSmall, - androidx.compose.material3.LocalContentColor provides MaterialTheme.colorScheme.primary - ) { - Box(Modifier.padding(bottom = 16.dp)) { - title() - } + } + title?.let { + CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.headlineSmall, + androidx.compose.material3.LocalContentColor provides MaterialTheme.colorScheme.primary + ) { + Box(Modifier.padding(bottom = 16.dp)) { + title() } } - content?.let { - CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) { - if (scrollContent) { - val scrollState = rememberScrollState() - Box(Modifier - .weight(weight = 1f, fill = false) - .padding(bottom = 24.dp) - .verticalScroll(scrollState) - ) { - content() - } - } else { - Box(Modifier.weight(weight = 1f, fill = false).padding(bottom = 24.dp)) { - content() - } + } + content?.let { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) { + if (scrollContent) { + val scrollState = rememberScrollState() + Box(Modifier + .weight(weight = 1f, fill = false) + .padding(bottom = 24.dp) + .verticalScroll(scrollState) + ) { + content() + } + } else { + Box(Modifier.weight(weight = 1f, fill = false).padding(bottom = 24.dp)) { + content() } } } - Row { - if (neutralButtonText != null) - TextButton( - onClick = onNeutral - ) { Text(neutralButtonText) } - Spacer(Modifier.weight(1f)) - TextButton(onClick = onDismissRequest) { Text(cancelButtonText) } - if (confirmButtonText != null) - TextButton( - enabled = checkOk(), - onClick = { onConfirmed(); onDismissRequest() }, - ) { Text(confirmButtonText) } - } + } + Row { + if (neutralButtonText != null) + TextButton( + onClick = onNeutral + ) { Text(neutralButtonText) } + Spacer(Modifier.weight(1f)) + TextButton(onClick = onDismissRequest) { Text(cancelButtonText) } + if (confirmButtonText != null) + TextButton( + enabled = checkOk(), + onClick = { onConfirmed(); onDismissRequest() }, + ) { Text(confirmButtonText) } } } } } } +@Composable +fun ThreeButtonAlertDialog( + onDismissRequest: () -> Unit, + onConfirmed: () -> Unit, + modifier: Modifier = Modifier, + icon: @Composable (() -> Unit)? = null, + title: @Composable (() -> Unit)? = null, + content: @Composable (() -> Unit)? = null, + scrollContent: Boolean = false, + onNeutral: () -> Unit = { }, + checkOk: () -> Boolean = { true }, + confirmButtonText: String? = stringResource(android.R.string.ok), + cancelButtonText: String = stringResource(android.R.string.cancel), + neutralButtonText: String? = null, + reducePadding: Boolean = false, + properties: DialogProperties = DialogProperties() +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = properties + ) { + ThreeButtonAlertDialogContent( + onDismissRequest = onDismissRequest, + onConfirmed = onConfirmed, + modifier = modifier, + icon = icon, + title = title, + content = content, + scrollContent = scrollContent, + onNeutral = onNeutral, + checkOk = checkOk, + confirmButtonText = confirmButtonText, + cancelButtonText = cancelButtonText, + neutralButtonText = neutralButtonText + ) + } +} + @Preview @Composable private fun Preview() { diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt index bc1cae203..e1df6b1e0 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt @@ -27,7 +27,7 @@ fun AIIntegrationScreen( return } - if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised") { + if (BuildConfig.FLAVOR == "standard") { StandardAIIntegrationScreen(onClickBack) } else { OfflineAIIntegrationScreen(onClickBack) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt index 6f03b8dcc..c5db7023b 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt @@ -536,7 +536,7 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( ) } }, - if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" || BuildConfig.FLAVOR == "offline") Setting(context, SettingsWithoutKey.CUSTOM_AI_KEYS, R.string.custom_ai_keys_title, R.string.custom_ai_keys_summary) { + if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "offline") Setting(context, SettingsWithoutKey.CUSTOM_AI_KEYS, R.string.custom_ai_keys_title, R.string.custom_ai_keys_summary) { Preference( name = it.title, description = it.description, diff --git a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt index cdac0851d..1f1a27a77 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt @@ -134,6 +134,23 @@ fun DictionaryScreen( } androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp)) + // Blocked Words Entry + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 16.dp) + .fillMaxWidth() + .clickable { SettingsDestination.navigateTo(SettingsDestination.BlockedWords) } + ) { + Text( + stringResource(R.string.edit_blocked_words), + style = MaterialTheme.typography.titleMedium + ) + NextScreenIcon() + } + androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp)) + // Blocklist Entry Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt index 3c0d9fef8..b55410549 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt @@ -96,7 +96,7 @@ fun LibrariesHubScreen( ) // Handwriting Input Plugin - if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised") { + if (BuildConfig.FLAVOR == "standard") { var handwritingInstalled by remember { mutableStateOf(HandwritingLoader.hasPlugin(context)) } LoadHandwritingPluginPreference( title = stringResource(R.string.libraries_hub_handwriting_title), @@ -105,7 +105,6 @@ fun LibrariesHubScreen( onSuccess = { handwritingInstalled = HandwritingLoader.hasPlugin(context) } ) } - // Documentation & Features val uriHandler = LocalUriHandler.current Preference( diff --git a/app/src/main/java/helium314/keyboard/settings/screens/SubtypeScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/SubtypeScreen.kt index 7bf751eda..4c1c7669c 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/SubtypeScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/SubtypeScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -89,6 +90,10 @@ import helium314.keyboard.settings.initPreview import helium314.keyboard.settings.layoutFilePicker import helium314.keyboard.settings.layoutIntent import helium314.keyboard.settings.previewDark +import helium314.keyboard.latin.handwriting.HandwritingLoader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.Locale @Composable @@ -232,6 +237,43 @@ fun SubtypeScreen( } } } + val recognizer = remember { HandwritingLoader.getRecognizer(ctx) } + val languageTag = currentSubtype.locale.toLanguageTag() + var isHandwritingDownloaded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + LaunchedEffect(languageTag) { + withContext(Dispatchers.IO) { + val ready = recognizer?.isLanguageReady(languageTag) == true + withContext(Dispatchers.Main) { + isHandwritingDownloaded = ready + } + } + } + if (isHandwritingDownloaded) { + WithSmallTitle(stringResource(R.string.handwriting)) { + ActionRow { + Text( + text = stringResource(R.string.delete_handwriting_model), + modifier = Modifier + .weight(1f) + .padding(start = 10.dp) + ) + DeleteButton { + scope.launch(Dispatchers.IO) { + val deleted = recognizer?.removeModel(languageTag) == true + withContext(Dispatchers.Main) { + if (deleted) { + isHandwritingDownloaded = false + android.widget.Toast.makeText(ctx, ctx.getString(R.string.handwriting_model_deleted), android.widget.Toast.LENGTH_SHORT).show() + } else { + android.widget.Toast.makeText(ctx, "Failed to delete handwriting model", android.widget.Toast.LENGTH_SHORT).show() + } + } + } + } + } + } + } // Divider removed to match modern MD3 look Text( stringResource(R.string.settings_screen_secondary_layouts), diff --git a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt index 215331ce8..29c6e31f6 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt @@ -99,8 +99,8 @@ fun createToolbarSettings(context: Context): List { val filter = { name: String -> val lowerName = name.lowercase() when { - lowerName.startsWith("custom_ai_") -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" || BuildConfig.FLAVOR == "offline" || BuildConfig.FLAVOR == "standardOptimised" - lowerName == "handwriting" -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" + lowerName.startsWith("custom_ai_") -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "offline" + lowerName == "handwriting" -> BuildConfig.FLAVOR == "standard" lowerName in listOf("proofread", "translate", "clipboard_search") -> BuildConfig.FLAVOR != "offlinelite" else -> true } diff --git a/app/src/main/res/layout/handwriting_view.xml b/app/src/main/res/layout/handwriting_view.xml index 86b475c83..7845c628b 100644 --- a/app/src/main/res/layout/handwriting_view.xml +++ b/app/src/main/res/layout/handwriting_view.xml @@ -31,6 +31,15 @@ android:textColor="?android:attr/textColorSecondary" android:textSize="14sp" /> + + - + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 97a09e119..c60a7a7a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -462,6 +462,8 @@ Handwriting plugin imported successfully Failed to load handwriting plugin APK Handwriting + Delete downloaded model + Handwriting model deleted Handwriting plugin required Please load the handwriting plugin library to enable drawing recognition. Load Plugin @@ -565,6 +567,13 @@ @android:string/cut Clipboard Clear clipboard + + Clipboard search + + Download + Downloading… + Download failed + Installed Voice input @string/layout_numpad Settings diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt index 33318f2fd..4e5b178e6 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -41,170 +41,170 @@ class ProofreadService(private val context: Context) { } val prefs: SharedPreferences get() = sharedPrefs - + // Singleton holder for model state to prevent reloading on every request - object ModelHolder { - var llamaHelper: LlamaHelper? = null - var currentModelPath: String? = null - var isModelAvailable: Boolean = true - var isModelLoaded: Boolean = false - - // Smart Unload Logic - private var unloadJob: Job? = null - private val scope = CoroutineScope(kotlinx.coroutines.SupervisorJob() + Dispatchers.IO) - private const val UNLOAD_DELAY_MS = 10 * 60 * 1000L // 10 minutes - private val loadMutex = Mutex() - - // Flow for LLM events - val llmFlow = MutableSharedFlow( - extraBufferCapacity = 64, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - - @Synchronized - fun scheduleUnload(context: Context) { - unloadJob?.cancel() - - val prefs = context.prefs() - val keepLoaded = prefs.getBoolean(Settings.PREF_OFFLINE_KEEP_MODEL_LOADED, Defaults.PREF_OFFLINE_KEEP_MODEL_LOADED) - - if (keepLoaded) { - Log.i(TAG, "Model unload skipped (Keep Model Loaded enabled)") - return - } +object ModelHolder { + var llamaHelper: LlamaHelper? = null + var currentModelPath: String? = null + var isModelAvailable: Boolean = true + var isModelLoaded: Boolean = false + + // Smart Unload Logic + private var unloadJob: Job? = null + private val scope = CoroutineScope(kotlinx.coroutines.SupervisorJob() + Dispatchers.IO) + private const val UNLOAD_DELAY_MS = 10 * 60 * 1000L // 10 minutes + private val loadMutex = Mutex() + + // Flow for LLM events + val llmFlow = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + @Synchronized + fun scheduleUnload(context: Context) { + unloadJob?.cancel() + + val prefs = context.prefs() + val keepLoaded = prefs.getBoolean(Settings.PREF_OFFLINE_KEEP_MODEL_LOADED, Defaults.PREF_OFFLINE_KEEP_MODEL_LOADED) + + if (keepLoaded) { + Log.i(TAG, "Model unload skipped (Keep Model Loaded enabled)") + return + } - unloadJob = scope.launch { - delay(UNLOAD_DELAY_MS) - unloadModel() - Log.i(TAG, "Offline AI model unloaded due to inactivity") - } + unloadJob = scope.launch { + delay(UNLOAD_DELAY_MS) + unloadModel() + Log.i(TAG, "Offline AI model unloaded due to inactivity") } + } + + @Synchronized + fun cancelUnload() { + unloadJob?.cancel() + unloadJob = null + } - @Synchronized - fun cancelUnload() { - unloadJob?.cancel() - unloadJob = null + @Synchronized + fun unloadModel() { + try { + llamaHelper?.release() + } catch (e: Exception) { + Log.w(TAG, "Error unloading llama model", e) } + llamaHelper = null + currentModelPath = null + isModelLoaded = false + isModelAvailable = true + } - @Synchronized - fun unloadModel() { - try { - llamaHelper?.release() - } catch (e: Exception) { - Log.w(TAG, "Error unloading llama model", e) - } - llamaHelper = null - currentModelPath = null - isModelLoaded = false - isModelAvailable = true + suspend fun loadModel( + context: Context, + modelPath: String + ): Boolean = loadMutex.withLock { + cancelUnload() + + // Check if already loaded with same path + if (isModelLoaded && currentModelPath == modelPath && llamaHelper != null) { + return true } - suspend fun loadModel( - context: Context, - modelPath: String - ): Boolean = loadMutex.withLock { - cancelUnload() + unloadModel() // Ensure clean slate if path changed - // Check if already loaded with same path - if (isModelLoaded && currentModelPath == modelPath && llamaHelper != null) { - return true - } + return try { + val contentResolver = context.contentResolver + val helper = LlamaHelper( + contentResolver, + scope, + llmFlow + ) - unloadModel() // Ensure clean slate if path changed - - return try { - val contentResolver = context.contentResolver - val helper = LlamaHelper( - contentResolver, - scope, - llmFlow - ) - - // Get llama via reflection - val llamaField = LlamaHelper::class.java.getDeclaredField("llama\$delegate").apply { isAccessible = true } - val llamaLazy = llamaField.get(helper) as Lazy - val llama = llamaLazy.value - - // Detach model file descriptor - val uri = android.net.Uri.parse(modelPath) - val pfd = contentResolver.openFileDescriptor(uri, "r") - ?: throw IllegalArgumentException("Failed to open model file descriptor") - val modelFd = pfd.detachFd() - - // Calculate optimal threads count (4 threads is the sweet spot for mobile CPUs) - val cores = Runtime.getRuntime().availableProcessors() - val threads = if (cores <= 4) cores else 4 - - Log.i(TAG, "Loading GGUF model: threads=$threads (cores=$cores), use_mmap=false") - - // Construct parameters map - val params = mutableMapOf( - "model" to modelPath, - "model_fd" to modelFd, - "use_mmap" to false, - "use_mlock" to false, - "n_ctx" to 2048, - "embedding" to false, - "n_batch" to 512, - "n_threads" to threads, - "n_gpu_layers" to 0, - "vocab_only" to false, - "lora" to "", - "lora_scaled" to 1.0, - "rope_freq_base" to 0.0, - "rope_freq_scale" to 0.0 - ) - - // JNI callback called by native code for each token - val callback: (String) -> Unit = { word -> - try { - val allTextField = LlamaHelper::class.java.getDeclaredField("allText").apply { isAccessible = true } - val currentAllText = allTextField.get(helper) as String - allTextField.set(helper, currentAllText + word) - - val tokenCountField = LlamaHelper::class.java.getDeclaredField("tokenCount").apply { isAccessible = true } - val currentCount = tokenCountField.get(helper) as Int - tokenCountField.set(helper, currentCount + 1) - - helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Ongoing(word, currentCount + 1)) - } catch (e: Throwable) { - Log.e(TAG, "Error in native token callback", e) - } - } + // Get llama via reflection + val llamaField = LlamaHelper::class.java.getDeclaredField("llama\$delegate").apply { isAccessible = true } + val llamaLazy = llamaField.get(helper) as Lazy + val llama = llamaLazy.value - // Start the engine - val result = llama.startEngine(params, callback) + // Detach model file descriptor + val uri = android.net.Uri.parse(modelPath) + val pfd = contentResolver.openFileDescriptor(uri, "r") + ?: throw IllegalArgumentException("Failed to open model file descriptor") + val modelFd = pfd.detachFd() - val contextId = result?.get("contextId") as? Int - ?: throw IllegalStateException("contextId not found in result map") + // Calculate optimal threads count (4 threads is the sweet spot for mobile CPUs) + val cores = Runtime.getRuntime().availableProcessors() + val threads = if (cores <= 4) cores else 4 - // Set currentContext via reflection - val currentContextField = LlamaHelper::class.java.getDeclaredField("currentContext").apply { isAccessible = true } - currentContextField.set(helper, contextId) + Log.i(TAG, "Loading GGUF model: threads=$threads (cores=$cores), use_mmap=false") - // Emit Loaded event - helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Loaded(modelPath)) + // Construct parameters map + val params = mutableMapOf( + "model" to modelPath, + "model_fd" to modelFd, + "use_mmap" to false, + "use_mlock" to false, + "n_ctx" to 2048, + "embedding" to false, + "n_batch" to 512, + "n_threads" to threads, + "n_gpu_layers" to 0, + "vocab_only" to false, + "lora" to "", + "lora_scaled" to 1.0, + "rope_freq_base" to 0.0, + "rope_freq_scale" to 0.0 + ) - llamaHelper = helper - currentModelPath = modelPath - isModelLoaded = true - isModelAvailable = true - true - } catch (e: Throwable) { - Log.e(TAG, "Failed to load GGUF model", e) - isModelAvailable = false - false + // JNI callback called by native code for each token + val callback: (String) -> Unit = { word -> + try { + val allTextField = LlamaHelper::class.java.getDeclaredField("allText").apply { isAccessible = true } + val currentAllText = allTextField.get(helper) as String + allTextField.set(helper, currentAllText + word) + + val tokenCountField = LlamaHelper::class.java.getDeclaredField("tokenCount").apply { isAccessible = true } + val currentCount = tokenCountField.get(helper) as Int + tokenCountField.set(helper, currentCount + 1) + + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Ongoing(word, currentCount + 1)) + } catch (e: Throwable) { + Log.e(TAG, "Error in native token callback", e) + } } - } - private const val TAG = "LlamaProofreadService" + // Start the engine + val result = llama.startEngine(params, callback) + + val contextId = result?.get("contextId") as? Int + ?: throw IllegalStateException("contextId not found in result map") + + // Set currentContext via reflection + val currentContextField = LlamaHelper::class.java.getDeclaredField("currentContext").apply { isAccessible = true } + currentContextField.set(helper, contextId) + + // Emit Loaded event + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Loaded(modelPath)) + + llamaHelper = helper + currentModelPath = modelPath + isModelLoaded = true + isModelAvailable = true + true + } catch (e: Throwable) { + Log.e(TAG, "Failed to load GGUF model", e) + isModelAvailable = false + false + } } + private const val TAG = "LlamaProofreadService" +} + // AI Provider support (API compatibility) enum class AIProvider { GEMINI, GROQ, OPENAI } - + fun getProvider(): AIProvider = AIProvider.GROQ fun setProvider(provider: AIProvider) { /* No-op */ } @@ -214,7 +214,7 @@ class ProofreadService(private val context: Context) { fun getApiKey(): String? = null fun setApiKey(apiKey: String?) { /* No-op */ } fun hasApiKey(): Boolean = false - + // HuggingFace stubs fun getHuggingFaceToken(): String? = null fun setHuggingFaceToken(token: String?) { /* No-op */ } @@ -231,7 +231,7 @@ class ProofreadService(private val context: Context) { // Model management - single model path (no encoder/decoder split) fun getModelPath(): String? = sharedPrefs.getString(KEY_MODEL_PATH, null) - + fun setModelPath(path: String?) { sharedPrefs.edit().apply { if (path.isNullOrBlank()) { @@ -246,7 +246,7 @@ class ProofreadService(private val context: Context) { // Decoder path (kept for API compatibility, not used with llamacpp) fun getDecoderPath(): String? = sharedPrefs.getString(KEY_DECODER_PATH, null) - + fun setDecoderPath(path: String?) { sharedPrefs.edit().apply { if (path.isNullOrBlank()) { @@ -260,7 +260,7 @@ class ProofreadService(private val context: Context) { // Tokenizer path (not needed with GGUF - tokenizer is embedded) fun getTokenizerPath(): String? = sharedPrefs.getString(KEY_TOKENIZER_PATH, null) - + fun setTokenizerPath(path: String?) { sharedPrefs.edit().apply { if (path.isNullOrBlank()) { @@ -287,7 +287,7 @@ class ProofreadService(private val context: Context) { fun getModelName(): String { val path = getModelPath() if (path.isNullOrBlank()) return "No Model Selected" - + if (path.startsWith("content://")) { try { val uri = Uri.parse(path) @@ -303,12 +303,12 @@ class ProofreadService(private val context: Context) { Log.w(TAG, "Failed to resolve content URI name", e) } } - + return File(path).name.takeIf { it.isNotEmpty() } ?: "Local Model" } fun setModelName(name: String) { /* No-op */ } - + fun getTargetLanguage(): String = "English" fun setTargetLanguage(language: String) { /* No-op */ } @@ -365,7 +365,7 @@ class ProofreadService(private val context: Context) { val topK = sharedPrefs.getInt(Settings.PREF_OFFLINE_TOP_K, Defaults.PREF_OFFLINE_TOP_K) val minP = sharedPrefs.getFloat(Settings.PREF_OFFLINE_MIN_P, Defaults.PREF_OFFLINE_MIN_P) val showThinkingVal = showThinking ?: sharedPrefs.getBoolean(Settings.PREF_OFFLINE_SHOW_THINKING, Defaults.PREF_OFFLINE_SHOW_THINKING) - + // Build the prompt val systemPrompt = overridePrompt ?: getSystemPrompt() val fullPrompt = if (systemPrompt.contains("{text}")) { @@ -394,7 +394,7 @@ class ProofreadService(private val context: Context) { "Input: $text\n" + "Output:" } - + // Collect generated text from the flow val generatedText = StringBuilder() val helper = ModelHolder.llamaHelper @@ -411,7 +411,7 @@ class ProofreadService(private val context: Context) { maxTokens = maxTokens, showThinking = showThinkingVal ) - + // Collect events until done ModelHolder.llmFlow.takeWhile { event -> when (event) { @@ -444,7 +444,7 @@ class ProofreadService(private val context: Context) { cleanedOutput = cleanedOutput.substring(text.length).trim() } } - + // Truncate at the first occurrence of subsequent template markers val markers = listOf("\nInput:", "\nInstruction:", "\nOutput:", "\nCorrected:", "Input:", "Instruction:", "Output:", "Corrected:") for (marker in markers) { @@ -455,14 +455,14 @@ class ProofreadService(private val context: Context) { } } } - + // Also truncate at any newline followed by a potential template header (e.g., "\nDraft email:", "\nCorrection:") val headerRegex = Regex("\\n[a-zA-Z0-9 ]+:") val match = headerRegex.find(cleanedOutput) if (match != null) { cleanedOutput = cleanedOutput.substring(0, match.range.first).trim() } - + // Also strip common prefixes that the model might generate or echo val prefixesToStrip = listOf( "Output:", "Corrected:", "Translation:", "Response:", "Result:", @@ -474,7 +474,7 @@ class ProofreadService(private val context: Context) { break } } - + // If the model wrapped the output in quotes, strip them if (cleanedOutput.startsWith("\"") && cleanedOutput.endsWith("\"")) { cleanedOutput = cleanedOutput.substring(1, cleanedOutput.length - 1).trim() @@ -482,7 +482,7 @@ class ProofreadService(private val context: Context) { if (cleanedOutput.startsWith("'") && cleanedOutput.endsWith("'")) { cleanedOutput = cleanedOutput.substring(1, cleanedOutput.length - 1).trim() } - + // Post-process to strip thinking/reasoning tags if showThinkingVal is false val finalOutput = if (!showThinkingVal) { stripThinkingTags(cleanedOutput) diff --git a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt index 49cb8584c..062d2dc46 100644 --- a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -17,6 +17,8 @@ class ProofreadService(private val context: Context) { GEMINI, GROQ, OPENAI } + + val prefs: SharedPreferences get() = context.prefs() // Always returns GEMINI as default, but methods do nothing diff --git a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt index 5230a545f..010ba170f 100644 --- a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -63,12 +63,12 @@ class ProofreadService(private val context: Context) { // Provider selection fun getProvider(): AIProvider { - val providerStr = context.prefs().getString(KEY_PROVIDER, AIProvider.GEMINI.name) - return try { - AIProvider.valueOf(providerStr ?: AIProvider.GEMINI.name) - } catch (e: IllegalArgumentException) { - AIProvider.GEMINI - } +val providerStr = context.prefs().getString(KEY_PROVIDER, AIProvider.GEMINI.name) +return try { + AIProvider.valueOf(providerStr ?: AIProvider.GEMINI.name) +} catch (e: IllegalArgumentException) { + AIProvider.GEMINI +} } fun setProvider(provider: AIProvider) { diff --git a/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt b/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt index a792af63b..e50e47d95 100644 --- a/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt +++ b/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt @@ -287,6 +287,29 @@ f""", // no newline at the end }]]""", Expected('.'.code, ".", popups = listOf(">").map { it to it.first().code })) } + @Test fun numberRowKeepsDigitsWhenShifted() { + val numberRowKey = """[[{ "label": "1", "popup": { + "relevant": [ + { "label": "!" }, + { "label": "¹" }, + { "label": "½" }, + { "label": "⅓" }, + { "label": "¼" }, + { "label": "⅛" } + ] + } }]]""" + val expected = Expected('1'.code, "1", popups = listOf("!", "¹", "½", "⅓", "¼", "⅛").map { it to it.first().code }) + listOf( + KeyboardId.ELEMENT_ALPHABET, + KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED, + KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED, + KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED + ).forEach { elementId -> + params.mId = KeyboardLayoutSet.getFakeKeyboardId(elementId) + assertIsExpected(numberRowKey, expected) + } + } + @Test fun nestedSelectors() { assertIsExpected("""[[{ "$": "shift_state_selector", "shiftedManual": { "code": 34, "label": "\"", "popup": { diff --git a/docs/badges/download.svg b/docs/badges/download.svg deleted file mode 100644 index ee156f654..000000000 --- a/docs/badges/download.svg +++ /dev/null @@ -1 +0,0 @@ -VersionVersionv3.8.5v3.8.5 diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg deleted file mode 100644 index df1200edc..000000000 --- a/docs/badges/downloads.svg +++ /dev/null @@ -1 +0,0 @@ -DownloadsDownloads3069330693 diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg deleted file mode 100644 index 66a371b67..000000000 --- a/docs/badges/stars.svg +++ /dev/null @@ -1 +0,0 @@ -StarsStars488488 diff --git a/fastlane/metadata/android/en-US/changelogs/3860.txt b/fastlane/metadata/android/en-US/changelogs/3860.txt index 475e3ca5d..da4da604d 100644 --- a/fastlane/metadata/android/en-US/changelogs/3860.txt +++ b/fastlane/metadata/android/en-US/changelogs/3860.txt @@ -1,3 +1,3 @@ - Two-thumb: down-swipe shortcut popup now aligns to the letter row (swipe down on a key selects the icon above it) - Flag learned/typed words that aren't in a dictionary; long-press to Add or Block them, plus a new Blocklist settings screen -- Fix two-thumb ghost-merge: a deleted or cancelled gesture trail no longer fuses into the next swipe +- Fix two-thumb ghost-merge: a deleted or cancelled gesture trail no longer fuses into the next swipe \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/3870.txt b/fastlane/metadata/android/en-US/changelogs/3870.txt new file mode 100644 index 000000000..69306f369 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3870.txt @@ -0,0 +1,3 @@ +- Fixed offline model +- Fixed handwriting plugin crash +- Added handwriting model download progress diff --git a/fastlane/metadata/android/en-US/changelogs/3880.txt b/fastlane/metadata/android/en-US/changelogs/3880.txt new file mode 100644 index 000000000..1efbc9aac --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3880.txt @@ -0,0 +1,13 @@ +- Exclude non-en-US dictionaries from standard flavor assets to reduce app size +- Add dynamic dictionary downloader and uninstaller for standard flavor +- Add download button on keyboard toolbar when layout dictionary is missing +- Allow deleting downloaded handwriting models in settings +- Tune double-tap shift timing and improve stability of handwriting gestures +- Restore close/search icons on clipboard toolbar +- Fix settings displaying disabled additional subtypes in dictionaries list +- Keep number row digits when keyboard is shifted to uppercase +- Fix WindowManager$BadTokenException crash on IME overlay dialogs +- Fix recently used emojis getting stuck on split toolbar +- Close emoji search automatically when dictionary download completes +- Show emoji dictionary download button on split toolbar when missing +- Show handwriting plugin download button on canvas when missing