diff --git a/README.md b/README.md
index 74a8a21a91..2bfc0e9b64 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ Does not use internet permission, and thus is 100% offline.
* [Code Contribution](CONTRIBUTING.md)
- [License](#license)
- [Credits](#credits)
+ * [Funding](#funding)
# Features
@@ -108,3 +109,11 @@ The icon is licensed under [Creative Commons BY-SA 4.0](https://creativecommons.
- [Indic Keyboard](https://gitlab.com/indicproject/indic-keyboard)
- [FlorisBoard](https://github.com/florisboard/florisboard/)
- Our [contributors](https://github.com/Helium314/HeliBoard/graphs/contributors)
+
+## Funding
+
+This project is funded through [NGI Mobifree Fund](https://nlnet.nl/mobifree), a fund established by [NLnet](https://nlnet.nl) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu) program. Learn more at the [NLnet project page](https://nlnet.nl/project/GestureTyping).
+
+[
](https://nlnet.nl)
+
+Further the project benefits from donations provided by many users (thank you all!).
diff --git a/app/src/debug/res/values/gesture_data.xml b/app/src/debug/res/values/gesture_data.xml
new file mode 100644
index 0000000000..483aebaa9f
--- /dev/null
+++ b/app/src/debug/res/values/gesture_data.xml
@@ -0,0 +1,7 @@
+
+
+
+ helium314.keyboard.debug.provider
+
diff --git a/app/src/debugNoMinify/res/values/gesture_data.xml b/app/src/debugNoMinify/res/values/gesture_data.xml
new file mode 100644
index 0000000000..483aebaa9f
--- /dev/null
+++ b/app/src/debugNoMinify/res/values/gesture_data.xml
@@ -0,0 +1,7 @@
+
+
+
+ helium314.keyboard.debug.provider
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5d9b3eb13c..5508a883c4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -101,6 +101,16 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only
+
+
+
+
diff --git a/app/src/main/assets/known_dict_hashes.txt b/app/src/main/assets/known_dict_hashes.txt
new file mode 100644
index 0000000000..a539514ff8
--- /dev/null
+++ b/app/src/main/assets/known_dict_hashes.txt
@@ -0,0 +1,126 @@
+# SHA256 hashes of dictionaries in https://codeberg.org/Helium314/aosp-dictionaries (relevant because here we also have the word list file)
+00c9c095e3e1619369ab62a3b4474ebb6eb78b1350a5366985635c1922aca780
+0266a78e525ed7decac2a005593bbe9459094340e9802647376dc8ac6a09203d
+027c742c6901d8ebcae667d2791cf9ad4543d0324c539a441a0611287de51af1
+08687a0119f7e8e9fb1ab30d8fb757c22cab9606dc38a5392192581bacc3dc53
+0c7f58a31f05ca04febc9499ecff692e514928e0c9251aff60a68e38c704b024
+0d02046c84e14c1a9fd541c9be869c4e558e40ae706c20b7667c2df36fc72b80
+0f4d1d60681a94c3e19c4c694f64d48b08f3904fb6bf724076e21a83ab9f7945
+11c43687913663c4f3bb5915085067358a18c9b8f0fd8759a191bd55028983c7
+144907462cf2a4c0d3b7c6e2aa67687a448a3f665ca449072ec433e85ff1b745
+14e94e229ba51fce0f40cab8a7dcfec4085c183712898f8a14d2016478ff6d1b
+166f60a0d3e11055215a5d3ee42ac6ce806d3d1a3fbc3137351c178bb667e58b
+17965a492f20ab1ba06f95704f9754a1f82e54be6581c50bb1d11b37e12a85a0
+17f8b5ff2227167d3a0a3cb436c8982b8957a94fe0e41bb11561c8cf4b51541f
+20ebf36d7297c06a229f6d3a8d18ab2c94cd02653aa23466110f93ac4caaa5ff
+211d08ea29ae85e9c449665a78f9fcefb6aac16a8b60d4642029b49b1e271fda
+244e81bd6c18841bfcd0d418908554b24cb512eed2c3abb3de6139bdb247b55f
+2841857c1bb2c251b0ba4bd452863d21e275edeac32fced8c8ef14ade0839eb3
+316e9a7e610b381f271926f3bd38d7bffd640befda99129f5f1092fa6b19a04a
+3532cfbee38003621c2d260d64690d8fbfdef419ab3508dcfc617a3b46df8590
+37151db7e9df12d7846e8d7ba936ff8aa441d13981c44971b61a4b86d104f422
+37d3519d23f19fe8c1ddafa76380af32cb8ab50f08544cbafe2a28255734461a
+38f0215d78b749ce8029952001846176a5dbbfe85f723c141e5b60fc1be910b8
+3c1bb98883ac5bc96a96e206972b5fc8c4a04ad8992d26c4d365ae0cdcc8f686
+410fb85388b646b6694373e83f30052040332acd2baaeb640574d3846c7c5ea4
+44a048bdb352b7b3f3d07a86ad1fb1692052b5a7838a57bc39caaae6181fcbad
+47155863f9fe3c188c9c5d4f850d02a4b499e9828fecc4bfb8305f65fedfcef2
+50d931a2a73acfbd5e2d69d14927981f985aefeda1067a37cf40d9d4d5b330e0
+537078e3ba369c87063031f03051d80a0ee121a5459f8192811ca254fc959d19
+54298a813d2425dc666882604fbc86f9487819417e25599f9660612be7d32da5
+57d1e2e7d0b08beec4d540952d0e538698c5d82f4e6a2cdba938800bfdc4a80d
+59e66fa3ac88783a1ad3e7db4b200c6206dfcebd459f4da30fffd895173c220c
+5b2d08c14128bcfb9d62db3cd5df8f0af8f7c2e653d9913f72f9f87d64648864
+5cc04de56805b03d4e9309e163b5360a2ab0206c962271c146117adb3c0823e2
+618dfbfba32bd69a44792ac6aa81c12528762b011e20691662058158c5d2f275
+61ce4cd1cd1961ab2ca2796964e4b1468eb48cb7568973c16f850348ebaedcc2
+642ff3e5727e81d3a1ebdeddd9226dd7c17166bcd52f8f6e39f34b44be6faabd
+6610bafc848961239f59b9f81a6f9b29b58fc31f89d5f4c50b4e7e1a8eb6f21e
+6850487b3985ea073f6f570fa5f489261c695e173552ec60bdd9fcedb0cc090f
+6aa8233c3c3533968b32d8a16079d73789936f03f663e55561494c3c655dd992
+6cc7bc856de7928606ec974a17887f80848c7692560824ab05153a48cb193c94
+6d0667373d4b6850690e328ac471a25a82aa838e412b646d97e73cac4d0a4573
+6e4c39ba5e471af8be2d8ea4a62d2cf0afded233481a3f7c6486889696014ffe
+72c3d30ef37e97eefc5dcec0a2ccd822190fe2e5da0958f6c775a610fb6e339a
+73fb7768d9a1eb38b232e296bb0a6e7f438031ba8d76cdeed11cd2b57868e8a0
+7bc8ae5c5b565050a478b82372d758c178804fc8c07d47cdb00ec314da2a7093
+7cf81b85f8602d970d1b4cd4ca8136e4a65313784fdf9011f17fdfc2e0b8f0c5
+7db7af0e5314bde1437ecfa191c992dc7650796759142ddca5041f71d2aee0a1
+7dd95ef84493bbb3860baa24f6afe8f83f79da931e4abf5a7ee921b9947a3e4f
+7eca55127764b36d7823b7ff86b4e2a0dabbf76bfc0d5659df657c6ea0f24f13
+806243bb3b799ef89c3aa1a147b5d12d2ec19271c6b3c0bbff7147af14c14c01
+813745bafaf928545cc215bfc96b68ad5c961fb1618ea2fea6ad230a1ddfa091
+8208a0d7374415cc610e2b605c44a0d2beed4920e44f63b1a9f5e838b95557a3
+8539600880f2586504fdcab610fd7d4966c161a2a235b1c927fe634d34c196fa
+861855eee7c7995d65b03746f3cb446b3831b92e8600f5ce221039ff8be05244
+87bc832f9b0f191907f1d08a12527a2c0e8854e23db807fa28af1936a4c0a87a
+89dbb28105af326a721c6ae7fb45e6a1e8f59c28f8a23f23731ef4d2ad10b90f
+8a43d036b300e22197452fcf7ca08af87c561ea5f4be144d861e13215034dac6
+8f0aea5aa50019691b0ec709b1874b9c0f2a954547d77d252967864834ccad72
+8f826109fd75c81354d950519983ced2205bf9c9844131924590e7ca8cac5746
+91710c15f1877f6a9b88b25a27de724b43eeb263b3c606e1512d606149f827b6
+91f919e3783787ccf009025d78fefb2233861567a6d41cbb3f6a282fee59558a
+92481712900b5f92e0b6621422d329753fe43a749e5bd75113ccbcd4882e003a
+94a808c9043d896b59357075c002c138555d66f6ed513a0a7215ce3fad84b028
+9937ec9a8929536ba6dea19f92e984b8a4aaea82d385dcbe239f9586bca415d8
+997fa17bbf9a077e5777412bcfe70ebf8c4fb09d5c822f09d442608ee9039551
+9a64cdf81cc873885df78c14a061eff8ab1bd8b8795f5e595a9e2030f83d2c58
+9bf6d8d2e076249d516ed18c7522d133d9ac8f4ce8061231fa82ede68f240e45
+9bfe41428b84b81c21405f53d3719aa8c5d2c96f41d74606e575d9b2a75d2757
+a06ae29a41d0a21b68aa295f4d9a05b62a0f8644d18c27ef5b27cb87bb06d833
+a1eaa344209680bb042d7ee0c66db1796101c43bf4942d4f496ee11ab63c5e49
+acf2164c98d715501cdcd34a8021791af3f08f6845bd245b740ba7a3e452d36b
+ade68d50d4a5a92eb1e3ad2d88a6a0f729d33a8d7ca420da9c7b405916b1dab7
+af4616eb74a7e1d61b74171ba0f22da424b31075b3c07652642e5c92adacabf5
+b090d0ebaf877d292d58aeb0b3eed4fbde8c63d0ec6ef7cd9977ba2fa0212f98
+09c576971a0a8131dc285e3a4ccb8aea039285d0951614e15e2fbf50d6693def
+b1e4ead01631d578bcae538d40cdb7a17fb4ee2721814e363dfeaf404765ae69
+b321f8c09afb8b3e3ce69d1b151bd8173cef1cb76f7a5993fbd4b3edea141b7f
+b6d905cdfdf18ee4743a561c7bf3ad33a9fd0266ea49e2d85e016fc08d2d3735
+bb2b5373748e3eb1daebfbdf66141102c5ffb63e3bba03326c8b00bfc7b7bb18
+bd950ef4b57655120eee65cee62a5d216a63f721d9a8bb759ce2022437840443
+bdd66c0873e65fb71e20f7c8dee85dd7ab9be59f627fff7521790ff8326c9f18
+beb2fda8a3bbaeeec95c301c5cce3e40474e01677a4412fb070eee2040c391d3
+c0794d9e5f85fbe623b7b3e617f2a9d7f45011648b289a0b263dfdafddac9332
+c8b106372ad532ce708fcf6e8b723cf89549c06273f77ba964f7eb5d56be176a
+c9e5607bd63143930b41ca91919bce23e8abca8172ad24a0fdede029398c95d7
+cb8d9163245752f96be76326e6fcedbe73ba6fde7373db4511157944d0970582
+cefbefc25ea656b4b8da5189366b1d1d608c205fcb1f83ec7e9ef4b76447937f
+d2317360a5af36b9db484892cbb2975d53cd40c01b9e8a2a1f23251be80cfa86
+d247e66bdc29a80b65a71df4a94b37e119a8c3ba8ca5956295028dfb285aa55f
+d45c1dc02a76e527bef7d330f8d1f15a4c4dffef8f58215f4bd5ea91e5f28ce6
+d4d88d10eb0d7023525f8bc7c24d5562ded72d0e75d4e3f8e8f0219f5dfc3a5f
+d5c863d7f424642d5317616d89135b9f1d6eb66cdb62652271d930a428f588b0
+d779d40bee274be2a5596e6df7882ac4685327c360810000ab5b900c6a79fe32
+d7b78b2eb4ee6a103acbd96b1cd1f20a87bd20f7c021ca2b069ca32d424282b2
+d7d80eaafe52dcdba057cdd7bed279f966c6e16ba501d46bb343ffa67732d62f
+d9ecea24f9b769462834c8760661678f62f33267bec0cc9b069e8fc2ae986e32
+dace1f700bb5335ed5c9e627a32dd02642c37f6f7fa524867786ca0496b20500
+df331ac2278a8c83507457eb71ef4e71c9c3ed0b9540f9786425401a38f739f1
+df554eb58ba6cfdc8017ea1cf71e61a65bdd36be6ad58ffb1a2c7cf02126e108
+e3c97d35a1b27c343abfd6d1c255289f776576201c2e061a7e7827723205a11d
+e5d70df2a2b1cde9ffce60d7cad28316864c01dd9123606c4ec79e5ae9a5123e
+e7b8bdb40871257c24e4741ade0ebfbc940a5e5264a8adb5682ad6c8d2e78249
+e89f2927c84d0d12d6b6509cd532a86ff2721747b0f4536c8c4f6e3995dd2b2e
+eb86c6ab70cb1486c759a159b4ca39cd19bf9ec099ea92afb24a76effa71fd11
+ed4d0a44db2fc11c53d2986b279520faa01ee4602af6a564023ea4565d41bf42
+ed8917d4039ba966c7f0647f0c3337c5cd715ef5e16fa73a8d28d982ea3469a5
+ede31f35db403ea05ebb722f5e445e1bf4c9f25833619e20558e1c3e7db11554
+ee5de04b6a93af67d2095a68ac1a8709ecdbf838cc5c98795f0b4ad2d192c2c7
+f10eef860d5cb06cd772360fe19dfe256799c52a78fdc31a0cd55634c909d3a2
+f19489ee41838c899dd44c3cb62b27a30f848d4f4f8af09e807b50542289a534
+f392e78a9719dd818acd75b7cd9ac5d49db8190fad394beb35dcae84fac20803
+f58d0bdadeba0f08330c1233448f47b18cb459eddd9f24bfcf56cf638d8c9013
+f7037e4714242fda95a3b4254d47228b9be537c35a125115b138d70318c30e70
+f7ab933be79d3994e8bda46ea39fcb569aeb0f5eea49043f0dce1888caee4d51
+fa9841c4a10fad7a422a61c84fb1c8bd7618b5b9d2debddaa84e1e95f841c1e8
+fd4ff64e40d0c7cd34914f473090b21c266e0ab9631b04a96e799fbe4879a5e4
+b9db272876025f0ae9878427a3f75aa3f2a108f1aa95e049043a4d93f83faf5c
+5b036c1fbac15712d81cc9144301332c357c1a64f1b9dc1dac0b6d06c42978fd
+1e147d575efda14bf17659f255395780a766fde14aca316b8a06065d04fcbdd9
+a179a6cc357e6bdefe1051d34df0e8513117678e7c4422a10f36bda005d29841
+1612f0c86aecc47c7f48a4c36f5169fb9240e5c22417b1cf8516d9497220c532
+1c0d451dfcc82172cd053c2da183ebc4c79a3b88ab06ad1e856396e00fe6b57d
+d37ed94c37d5638dfcd847ee288fc0db7281f7ae5a7cf448751ad333faffbf30
+150a376283d4b84f568c3ff2ce2d2619e540c4ecf098c39b39e2c0b9b2a9f697
+bc93c0b59c4059442c9b451f213aec5d83e8c389736cf325de387bdd4770e1ae
diff --git a/app/src/main/java/com/android/inputmethod/latin/BinaryDictionary.java b/app/src/main/java/com/android/inputmethod/latin/BinaryDictionary.java
index 0aa62b0795..a2f7066eba 100644
--- a/app/src/main/java/com/android/inputmethod/latin/BinaryDictionary.java
+++ b/app/src/main/java/com/android/inputmethod/latin/BinaryDictionary.java
@@ -7,6 +7,7 @@
package com.android.inputmethod.latin;
import android.text.TextUtils;
+import helium314.keyboard.latin.utils.ChecksumCalculator;
import helium314.keyboard.latin.utils.Log;
import android.util.SparseArray;
@@ -643,6 +644,20 @@ private synchronized void closeInternalLocked() {
}
}
+ private String mDictFileHash;
+ public String getHash() {
+ if (mDictFileHash != null) return mDictFileHash;
+ final File dict = new File(mDictFilePath);
+ if (!dict.isFile()) {
+ mDictFileHash = "";
+ return mDictFileHash;
+ }
+ mDictFileHash = ChecksumCalculator.INSTANCE.checksum(dict);
+ if (mDictFileHash == null)
+ mDictFileHash = "";
+ return mDictFileHash;
+ }
+
// TODO: Manage BinaryDictionary instances without using WeakReference or something.
@Override
protected void finalize() throws Throwable {
diff --git a/app/src/main/java/helium314/keyboard/latin/AppsManager.kt b/app/src/main/java/helium314/keyboard/latin/AppsManager.kt
index 70fb7dcef1..9da8455f0e 100644
--- a/app/src/main/java/helium314/keyboard/latin/AppsManager.kt
+++ b/app/src/main/java/helium314/keyboard/latin/AppsManager.kt
@@ -48,4 +48,11 @@ class AppsManager(val context: Context) : BroadcastReceiver() {
interface AppsChangedListener {
fun onAppsChanged()
}
+
+ fun getPackagesAndNames(): Collection> {
+ val filter = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
+ return mPackageManager.queryIntentActivities(filter, 0).distinctBy { it.activityInfo.packageName }.map {
+ it.activityInfo.packageName to it.activityInfo.loadLabel(mPackageManager).toString()
+ }
+ }
}
diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java
index fe5458ab61..c44fb7bc69 100644
--- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java
+++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java
@@ -68,6 +68,7 @@
import helium314.keyboard.latin.suggestions.SuggestionStripViewAccessor;
import helium314.keyboard.latin.touchinputconsumer.GestureConsumer;
import helium314.keyboard.latin.utils.ColorUtilKt;
+import helium314.keyboard.latin.utils.GestureDataGatheringKt;
import helium314.keyboard.latin.utils.InlineAutofillUtils;
import helium314.keyboard.latin.utils.InputMethodPickerKt;
import helium314.keyboard.latin.utils.JniUtils;
@@ -124,8 +125,9 @@ public class LatinIME extends InputMethodService implements
// UIHandler is needed when creating InputLogic
public final UIHandler mHandler = new UIHandler(this);
- private final DictionaryFacilitator mDictionaryFacilitator =
+ private DictionaryFacilitator mDictionaryFacilitator = // non-final for active gesture data gathering, revert when data gathering phase is done (end of 2026 latest)
DictionaryFacilitatorProvider.getDictionaryFacilitator(false);
+ private final DictionaryFacilitator mOriginalDictionaryFacilitator = mDictionaryFacilitator;
final InputLogic mInputLogic = new InputLogic(this, this, mDictionaryFacilitator);
// TODO: Move these {@link View}s to {@link KeyboardSwitcher}.
@@ -831,6 +833,15 @@ private void onStartInputInternal(final EditorInfo editorInfo, final boolean res
void onStartInputViewInternal(final EditorInfo editorInfo, final boolean restarting) {
super.onStartInputView(editorInfo, restarting);
+ // only for active gesture data gathering, remove when data gathering phase is done (end of 2026 latest)
+ if (GestureDataGatheringKt.isInActiveGatheringMode(editorInfo)) {
+ mDictionaryFacilitator = GestureDataGatheringKt.getGestureDataActiveFacilitator();
+ } else {
+ mDictionaryFacilitator = mOriginalDictionaryFacilitator;
+ }
+ GestureDataGatheringKt.showEndNotificationIfNecessary(this); // will do nothing for a long time
+ mInputLogic.setFacilitator(mDictionaryFacilitator);
+
mDictionaryFacilitator.onStartInput();
// Switch to the null consumer to handle cases leading to early exit below, for which we
// also wouldn't be consuming gesture data.
diff --git a/app/src/main/java/helium314/keyboard/latin/database/Database.kt b/app/src/main/java/helium314/keyboard/latin/database/Database.kt
index fa85c2469d..edc88ca0de 100644
--- a/app/src/main/java/helium314/keyboard/latin/database/Database.kt
+++ b/app/src/main/java/helium314/keyboard/latin/database/Database.kt
@@ -4,21 +4,25 @@ package helium314.keyboard.latin.database
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
+import helium314.keyboard.latin.utils.GestureDataDao
import helium314.keyboard.latin.utils.Log
import java.io.File
class Database private constructor(context: Context, name: String = NAME) : SQLiteOpenHelper(context, name, null, VERSION) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(ClipboardDao.CREATE_TABLE)
+ onUpgrade(db, 0, VERSION)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
- // nothing yet
+ if (oldVersion <= 1) {
+ db.execSQL(GestureDataDao.CREATE_TABLE)
+ }
}
companion object {
private val TAG = Database::class.java.simpleName
- private const val VERSION = 1
+ private const val VERSION = 2
const val NAME = "heliboard.db"
private var instance: Database? = null
fun getInstance(context: Context): Database {
diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/ReadOnlyBinaryDictionary.java b/app/src/main/java/helium314/keyboard/latin/dictionary/ReadOnlyBinaryDictionary.java
index 170f57042a..ce4c66f445 100644
--- a/app/src/main/java/helium314/keyboard/latin/dictionary/ReadOnlyBinaryDictionary.java
+++ b/app/src/main/java/helium314/keyboard/latin/dictionary/ReadOnlyBinaryDictionary.java
@@ -130,4 +130,8 @@ public void close() {
mLock.writeLock().unlock();
}
}
+
+ public String getHash() {
+ return mBinaryDictionary.getHash();
+ }
}
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 afa7162e66..d949e7155d 100644
--- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
+++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
@@ -90,9 +90,14 @@ public final class InputLogic {
private int mSpaceState;
// Never null
public SuggestedWords mSuggestedWords = SuggestedWords.getEmptyInstance();
- public final Suggest mSuggest;
- private final DictionaryFacilitator mDictionaryFacilitator;
+ public Suggest mSuggest; // non-final for active gesture data gathering, revert when data gathering phase is done (end of 2026 latest)
+ public DictionaryFacilitator mDictionaryFacilitator; // non-final for active gesture data gathering, revert when data gathering phase is done (end of 2026 latest)
private SingleDictionaryFacilitator mEmojiDictionaryFacilitator;
+ public void setFacilitator(DictionaryFacilitator facilitator) { // only for active gesture data gathering, remove when data gathering phase is done (end of 2026 latest)
+ if (mDictionaryFacilitator == facilitator) return;
+ mDictionaryFacilitator = facilitator;
+ mSuggest = new Suggest(mDictionaryFacilitator);
+ }
public LastComposedWord mLastComposedWord = LastComposedWord.NOT_A_COMPOSED_WORD;
// This has package visibility so it can be accessed from InputLogicHandler.
diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ChecksumCalculator.kt b/app/src/main/java/helium314/keyboard/latin/utils/ChecksumCalculator.kt
index 805147ada1..7ec5408443 100644
--- a/app/src/main/java/helium314/keyboard/latin/utils/ChecksumCalculator.kt
+++ b/app/src/main/java/helium314/keyboard/latin/utils/ChecksumCalculator.kt
@@ -33,5 +33,7 @@ object ChecksumCalculator {
return s.toString()
}
- fun checksum(file: File) = file.inputStream().use { checksum(it) }
+ fun checksum(file: File) = runCatching {
+ file.inputStream().use { checksum(it) }
+ }.getOrNull()
}
diff --git a/app/src/main/java/helium314/keyboard/latin/utils/GestureDataGathering.kt b/app/src/main/java/helium314/keyboard/latin/utils/GestureDataGathering.kt
new file mode 100644
index 0000000000..b9b00489f7
--- /dev/null
+++ b/app/src/main/java/helium314/keyboard/latin/utils/GestureDataGathering.kt
@@ -0,0 +1,437 @@
+// SPDX-License-Identifier: GPL-3.0-only
+package helium314.keyboard.latin.utils
+
+import android.content.ContentValues
+import android.content.Context
+import android.text.InputType
+import android.view.inputmethod.EditorInfo
+import androidx.core.content.edit
+import com.android.inputmethod.latin.BinaryDictionary
+import helium314.keyboard.keyboard.Keyboard
+import helium314.keyboard.keyboard.KeyboardSwitcher
+import helium314.keyboard.latin.BuildConfig
+import helium314.keyboard.latin.InputAttributes
+import helium314.keyboard.latin.NgramContext
+import helium314.keyboard.latin.R
+import helium314.keyboard.latin.SingleDictionaryFacilitator
+import helium314.keyboard.latin.SuggestedWords
+import helium314.keyboard.latin.common.ComposedData
+import helium314.keyboard.latin.common.Constants
+import helium314.keyboard.latin.common.InputPointers
+import helium314.keyboard.latin.common.StringUtils
+import helium314.keyboard.latin.database.Database
+import helium314.keyboard.latin.dictionary.Dictionary
+import helium314.keyboard.latin.dictionary.ReadOnlyBinaryDictionary
+import helium314.keyboard.latin.settings.Settings
+import helium314.keyboard.settings.screens.gesturedata.END_DATE_EPOCH_MILLIS
+import helium314.keyboard.settings.screens.gesturedata.TWO_WEEKS_IN_MILLIS
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import java.text.DateFormat
+import java.util.Date
+import kotlin.random.Random
+
+// functionality for gesture data gathering as part of the NLNet Project https://nlnet.nl/project/GestureTyping/
+// will be removed once the project is finished
+
+fun isInActiveGatheringMode(editorInfo: EditorInfo) =
+ dictTestImeOption == editorInfo.privateImeOptions && gestureDataActiveFacilitator != null
+
+fun setWordIgnoreList(context: Context, list: Collection) {
+ val json = Json.encodeToString(list)
+ context.prefs().edit { putString(PREF_WORD_EXCLUSIONS, json) }
+}
+
+fun getWordIgnoreList(context: Context): Set {
+ val json = context.prefs().getString(PREF_WORD_EXCLUSIONS, "[]") ?: "[]"
+ if (json.isEmpty()) return sortedSetOf()
+ return Json.decodeFromString>(json).toSortedSet(compareBy(String.CASE_INSENSITIVE_ORDER) { it })
+}
+
+fun setAppIgnoreList(context: Context, list: Collection) {
+ context.prefs().edit { putString(PREF_APP_EXCLUSIONS, list.joinToString(",")) }
+}
+
+fun getAppIgnoreList(context: Context): List {
+ val string = context.prefs().getString(PREF_APP_EXCLUSIONS, "") ?: ""
+ return string.split(",").filterNot { it.isEmpty() }
+}
+
+fun addExportedActiveDeletionCount(context: Context, count: Int) {
+ val oldCount = getExportedActiveDeletionCount(context)
+ context.prefs().edit { putInt(PREF_DELETED_ACTIVE, oldCount + count) }
+}
+
+fun getExportedActiveDeletionCount(context: Context) = context.prefs().getInt(PREF_DELETED_ACTIVE, 0)
+
+/** shows a toast notification if we're close to the end of the data gathering phase (at most once per 24 hours, only if there is non-exported data) */
+fun showEndNotificationIfNecessary(context: Context) {
+ val now = System.currentTimeMillis()
+ if (now < END_DATE_EPOCH_MILLIS - TWO_WEEKS_IN_MILLIS) return
+ val lastShown = context.prefs().getLong(PREF_END_NOTIFICATION_LAST_SHOWN, 0)
+ if (lastShown > now - 24L * 60 * 60 * 1000) return // show at most once per 24 hours
+ context.prefs().edit { putLong(PREF_END_NOTIFICATION_LAST_SHOWN, now) } // set even if we have nothing to tell
+ val notExported = GestureDataDao.getInstance(context)?.count(exported = false) ?: 0
+ if (notExported == 0) return // nothing to export
+
+ // show a toast
+ val endDate = DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(END_DATE_EPOCH_MILLIS))
+ KeyboardSwitcher.getInstance().showToast(context.getString(R.string.gesture_data_ends_at, endDate), false)
+}
+
+private const val PREF_WORD_EXCLUSIONS = "gesture_data_word_exclusions"
+private const val PREF_APP_EXCLUSIONS = "gesture_data_app_exclusions"
+private const val PREF_DELETED_ACTIVE = "gesture_data_deleted_active_words"
+private const val PREF_PASSIVE_NOTIFY_COUNT = "gesture_data_passive_notify_count"
+private const val PREF_END_NOTIFICATION_LAST_SHOWN = "gesture_data_end_notification_shown"
+
+const val dictTestImeOption = "useTestDictionaryFacilitator,${BuildConfig.APPLICATION_ID}.${Constants.ImeOption.NO_FLOATING_GESTURE_PREVIEW}"
+
+var gestureDataActiveFacilitator: SingleDictionaryFacilitator? = null
+
+// class for storing relevant information
+class WordData(
+ val targetWord: String,
+ val suggestions: SuggestionResults,
+ val composedData: ComposedData,
+ val ngramContext: NgramContext,
+ val keyboard: Keyboard,
+ val inputStyle: Int,
+ val activeMode: Boolean,
+) {
+ // keyboard is not immutable, so better store potentially relevant information immediately
+ private val keys = keyboard.sortedKeys
+ private val height = keyboard.mOccupiedHeight
+ private val width = keyboard.mOccupiedWidth
+
+ // if contacts dict is used we keep this information
+ private val dictionariesInSuggestions = suggestions.map { it.mSourceDict }.toSet()
+ private val packageName = keyboard.mId.mEditorInfo.packageName
+
+ private val timestamp = System.currentTimeMillis()
+
+ fun save(context: Context) {
+ if (!isSavingOk(context))
+ return
+ val dao = GestureDataDao.getInstance(context) ?: return
+
+ val keyboardInfo = KeyboardInfo(
+ width, // baseHeight is without padding, but coordinates include padding
+ height,
+ keys.map {
+ KeyInfo(
+ it.x, it.width, it.y, it.height,
+ it.outputText ?: if (it.code > 0) StringUtils.newSingleCodePointString(it.code) else "",
+ it.popupKeys.orEmpty().map { popup ->
+ popup.mOutputText ?: if (popup.mCode > 0) StringUtils.newSingleCodePointString(popup.mCode) else ""
+ }
+ )
+ }
+ )
+ val filteredSuggestions = mutableListOf()
+ for (word in suggestions) { // suggestions are sorted with highest score first
+ if (word.mSourceDict.mDictType == Dictionary.TYPE_CONTACTS
+ || suggestions.any { it.mWord == word.mWord && it.mSourceDict.mDictType == Dictionary.TYPE_CONTACTS })
+ continue // never store contacts (might be in user history too)
+ // for the personal dictionary we rely on the ignore list
+ if (word.mScore < 0 && filteredSuggestions.size > 5)
+ continue // no need to add bad matches
+ if (filteredSuggestions.any { it.mWord == word.mWord })
+ continue // only one occurrence per word
+ if (filteredSuggestions.size > 12)
+ continue // should be enough
+ filteredSuggestions.add(word)
+ }
+ val data = GestureData(
+ context.getString(R.string.english_ime_name) + " " + BuildConfig.VERSION_NAME,
+ if (!context.protectedPrefs().contains(Settings.PREF_LIBRARY_CHECKSUM)) null
+ else context.protectedPrefs().getString(Settings.PREF_LIBRARY_CHECKSUM, "") == JniUtils.expectedDefaultChecksum(),
+ targetWord,
+ dictionariesInSuggestions.map {
+ val hash = (it as? BinaryDictionary)?.hash ?: (it as? ReadOnlyBinaryDictionary)?.hash
+ DictInfo(hash, it.mDictType, it.mLocale?.toLanguageTag())
+ },
+ filteredSuggestions.map { Suggestion(it.mWord, it.mScore) },
+ PointerData.fromPointers(composedData.mInputPointers),
+ keyboardInfo,
+ activeMode,
+ null
+ )
+ dao.add(data, timestamp)
+ informAboutTooManyPassiveModeWords(context, dao)
+ }
+
+ // show a toast every 5k words, to avoid having to upload multiple files at a time because they are over their email attachment size limit
+ // but don't check on every word, because getting count from DB is not free
+ private fun informAboutTooManyPassiveModeWords(context: Context, dao: GestureDataDao) {
+ if (!activeMode || Random.nextInt() % 20 != 0) return
+ val count = dao.count(exported = false, activeMode = false)
+ val nextNotifyCount = context.prefs().getInt(PREF_PASSIVE_NOTIFY_COUNT, 5000)
+ if (count <= nextNotifyCount) return
+ val approxCount = (count / 1000) * 1000
+ // show a toast
+ KeyboardSwitcher.getInstance().showToast(context.getString(R.string.gesture_data_many_not_shared_words, approxCount.toString()), true)
+ context.prefs().edit { putInt(PREF_PASSIVE_NOTIFY_COUNT, approxCount + 5000) }
+ }
+
+ // find when we should NOT save
+ private fun isSavingOk(context: Context): Boolean {
+ if (inputStyle != SuggestedWords.INPUT_STYLE_TAIL_BATCH)
+ return false
+ if (activeMode && dictionariesInSuggestions.size == 1)
+ return true // active mode should be fine, the size check is just an addition in case there is a bug that sets the wrong mode or dictionary facilitator
+ if (Settings.getValues().mIncognitoModeEnabled)
+ return false // don't save in incognito mode
+ if (packageName in getAppIgnoreList(context))
+ return false // package ignored
+ val inputAttributes = InputAttributes(keyboard.mId.mEditorInfo, false, "")
+ val isEmailField = InputTypeUtils.isEmailVariation(inputAttributes.mInputType and InputType.TYPE_MASK_VARIATION)
+ if (inputAttributes.mIsPasswordField || inputAttributes.mNoLearning || isEmailField)
+ return false // probably some more inputAttributes to consider
+ val ignoreWords = getWordIgnoreList(context)
+ // how to deal with the ignore list?
+ // check targetWord and first 5 suggestions?
+ // or check only what is in the actually saved suggestions?
+ if (targetWord in ignoreWords || suggestions.take(5).any { it.word in ignoreWords })
+ return false
+ if (suggestions.first().mSourceDict.mDictType == Dictionary.TYPE_CONTACTS)
+ return false
+ return true
+ }
+}
+
+data class GestureDataInfo(val id: Long, val targetWord: String, val timestamp: Long, val exported: Boolean, val activeMode: Boolean)
+
+@Serializable
+data class GestureData(
+ val application: String,
+ val knownLibrary: Boolean?,
+ val targetWord: String?, // this will be tricky for active gathering if user corrects the word
+ val dictionaries: List,
+ val suggestions: List,
+ val gesture: List,
+ val keyboardInfo: KeyboardInfo,
+ val activeMode: Boolean,
+ val uuid: String?
+)
+
+// hash is only available for dictionaries from .dict files
+// language can be null (but should not be)
+@Serializable
+data class DictInfo(val hash: String?, val type: String, val language: String?)
+
+@Serializable
+data class Suggestion(val word: String, val score: Int)
+
+@Serializable
+data class PointerData(val id: Int, val x: Int, val y: Int, val millis: Int) {
+ companion object {
+ fun fromPointers(pointers: InputPointers): List {
+ val result = mutableListOf()
+ for (i in 0..)
+
+@Serializable
+data class KeyboardInfo(val width: Int, val height: Int, val keys: List)
+
+class GestureDataDao(val db: Database) {
+ fun add(data: GestureData, timestamp: Long) {
+ require(data.uuid == null)
+ val jsonString = Json.encodeToString(data)
+ // if uuid in the resulting string is replaced with null, we should be able to reproduce it
+ val dataWithId = data.copy(uuid = ChecksumCalculator.checksum(jsonString.byteInputStream()))
+ val cv = ContentValues(3)
+ cv.put(COLUMN_TIMESTAMP, timestamp)
+ cv.put(COLUMN_WORD, data.targetWord)
+ if (data.activeMode)
+ cv.put(COLUMN_SOURCE_ACTIVE, 1)
+ cv.put(COLUMN_DATA, Json.encodeToString(dataWithId))
+ db.writableDatabase.insert(TABLE, null, cv)
+ }
+
+ fun filterInfos(
+ word: String? = null,
+ begin: Long? = null,
+ end: Long? = null,
+ exported: Boolean? = null,
+ activeMode: Boolean? = null,
+ limit: Int? = null
+ ): List {
+ val result = mutableListOf()
+ val query = mutableListOf()
+ if (word != null) query.add("LOWER($COLUMN_WORD) like ?||'%'")
+ if (begin != null) query.add("$COLUMN_TIMESTAMP >= $begin")
+ if (end != null) query.add("$COLUMN_TIMESTAMP <= $end")
+ if (exported != null) query.add("$COLUMN_EXPORTED = ${if (exported) 1 else 0}")
+ if (activeMode != null) query.add("$COLUMN_SOURCE_ACTIVE = ${if (activeMode) 1 else 0}")
+ db.readableDatabase.query(
+ TABLE,
+ arrayOf(COLUMN_ID, COLUMN_WORD, COLUMN_TIMESTAMP, COLUMN_EXPORTED, COLUMN_SOURCE_ACTIVE),
+ query.joinToString(" AND "),
+ word?.let { arrayOf(it.lowercase()) },
+ null,
+ null,
+ null,
+ limit?.toString()
+ ).use {
+ while (it.moveToNext()) {
+ result.add(GestureDataInfo(
+ it.getLong(0),
+ it.getString(1),
+ it.getLong(2),
+ it.getInt(3) != 0,
+ it.getInt(4) != 0
+ ))
+ }
+ }
+ return result
+ }
+
+ fun getJsonData(ids: List): List {
+ val result = mutableListOf()
+ db.readableDatabase.query(
+ TABLE,
+ arrayOf(COLUMN_DATA),
+ "$COLUMN_ID IN (${ids.joinToString(",")})",
+ null,
+ null,
+ null,
+ null
+ ).use {
+ while (it.moveToNext()) {
+ result.add(it.getString(0))
+ }
+ }
+ return result
+ }
+
+ fun getAllJsonData(): List {
+ val result = mutableListOf()
+ db.readableDatabase.query(
+ TABLE,
+ arrayOf(COLUMN_DATA),
+ null,
+ null,
+ null,
+ null,
+ null
+ ).use {
+ while (it.moveToNext()) {
+ result.add(it.getString(0))
+ }
+ }
+ return result
+ }
+
+ fun markAsExported(ids: List, context: Context) {
+ if (ids.isEmpty()) return
+ val cv = ContentValues(1)
+ cv.put(COLUMN_EXPORTED, 1)
+ db.writableDatabase.update(TABLE, cv, "$COLUMN_ID IN (${ids.joinToString(",")})", null)
+ if (count(exported = false, activeMode = false) < context.prefs().getInt(PREF_PASSIVE_NOTIFY_COUNT, 0))
+ context.prefs().edit { remove(PREF_PASSIVE_NOTIFY_COUNT) } // reset if we exported passive data
+ }
+
+ fun delete(ids: List, onlyExported: Boolean, context: Context): Int {
+ if (ids.isEmpty()) return 0
+ val where = "$COLUMN_ID IN (${ids.joinToString(",")})"
+ val whereExported = " AND $COLUMN_EXPORTED <> 0"
+ val count: Int
+ if (onlyExported) {
+ count = db.writableDatabase.delete(TABLE, where + whereExported, null)
+ addExportedActiveDeletionCount(context, count) // actually we could also have a counter in the db
+ } else {
+ val exportedCount = db.readableDatabase.rawQuery("SELECT COUNT(1) FROM $TABLE WHERE $where$whereExported", null).use {
+ it.moveToFirst()
+ it.getInt(0)
+ }
+ count = db.writableDatabase.delete(TABLE, where, null)
+ addExportedActiveDeletionCount(context, exportedCount)
+ }
+ return count
+ }
+
+ fun deleteAll() {
+ db.writableDatabase.delete(TABLE, null, null)
+ }
+
+ fun deletePassiveWords(words: Collection) {
+ val wordsString = words.joinToString("','") { it.lowercase() }
+ db.writableDatabase.delete(
+ TABLE,
+ "$COLUMN_SOURCE_ACTIVE <> 0 AND LOWER($COLUMN_WORD) in (?)",
+ arrayOf(wordsString)
+ )
+ }
+
+ fun count(exported: Boolean? = null, activeMode: Boolean? = null): Int {
+ val where = mutableListOf()
+ if (exported != null)
+ where.add("$COLUMN_EXPORTED ${if (exported) "<>" else "="} 0")
+ if (activeMode != null)
+ where.add("$COLUMN_SOURCE_ACTIVE ${if (activeMode) "<>" else "="} 0")
+ val whereString = if (where.isEmpty()) "" else "WHERE ${where.joinToString(" AND ")}"
+ return db.readableDatabase.rawQuery("SELECT COUNT(1) FROM $TABLE $whereString", null).use {
+ it.moveToFirst()
+ it.getInt(0)
+ }
+ }
+
+ fun isEmpty(): Boolean {
+ db.readableDatabase.rawQuery("SELECT EXISTS (SELECT 1 FROM $TABLE)", null).use {
+ it.moveToFirst()
+ return it.getInt(0) == 0
+ }
+ }
+
+ companion object {
+ private const val TAG = "GestureDataDao"
+
+ private const val TABLE = "GESTURE_DATA"
+ private const val COLUMN_ID = "ID"
+ private const val COLUMN_TIMESTAMP = "TIMESTAMP"
+ private const val COLUMN_WORD = "WORD"
+ private const val COLUMN_EXPORTED = "EXPORTED"
+ private const val COLUMN_SOURCE_ACTIVE = "SOURCE_ACTIVE"
+ private const val COLUMN_DATA = "DATA" // data is text, blob with zip is slower to store, and probably not worth the saved space
+
+ const val CREATE_TABLE = """
+ CREATE TABLE IF NOT EXISTS $TABLE (
+ $COLUMN_ID INTEGER PRIMARY KEY,
+ $COLUMN_TIMESTAMP INTEGER NOT NULL,
+ $COLUMN_WORD TEXT NOT NULL,
+ $COLUMN_EXPORTED TINYINT NOT NULL DEFAULT 0,
+ $COLUMN_SOURCE_ACTIVE TINYINT NOT NULL DEFAULT 0,
+ $COLUMN_DATA TEXT
+ )
+ """
+
+ private var instance: GestureDataDao? = null
+
+ /** Returns the instance or creates a new one. Returns null if instance can't be created (e.g. no access to db due to device being locked) */
+ fun getInstance(context: Context): GestureDataDao? {
+ if (instance == null)
+ try {
+ instance = GestureDataDao(Database.getInstance(context))
+ } catch (e: Throwable) {
+ Log.e(TAG, "can't create ClipboardDao", e)
+ }
+ return instance
+ }
+ }
+}
diff --git a/app/src/main/java/helium314/keyboard/settings/Preview.kt b/app/src/main/java/helium314/keyboard/settings/Preview.kt
index d2b0edaecd..a0f54d68c6 100644
--- a/app/src/main/java/helium314/keyboard/settings/Preview.kt
+++ b/app/src/main/java/helium314/keyboard/settings/Preview.kt
@@ -11,6 +11,7 @@ import helium314.keyboard.latin.utils.SubtypeSettings
fun initPreview(context: Context) {
Settings.init(context)
SubtypeSettings.init(context)
+ Settings.getInstance().loadSettings(context)
SettingsActivity.settingsContainer = SettingsContainer(context)
KeyboardIconsSet.instance.loadIcons(context)
}
diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt
index eaeb9ff564..cd4f49407c 100644
--- a/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt
+++ b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt
@@ -32,6 +32,7 @@ import helium314.keyboard.settings.screens.SecondaryLayoutScreen
import helium314.keyboard.settings.screens.SubtypeScreen
import helium314.keyboard.settings.screens.TextCorrectionScreen
import helium314.keyboard.settings.screens.ToolbarScreen
+import helium314.keyboard.settings.screens.gesturedata.GestureDataScreen
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -70,6 +71,7 @@ fun SettingsNavHost(
onClickPreferences = { navController.navigate(SettingsDestination.Preferences) },
onClickToolbar = { navController.navigate(SettingsDestination.Toolbar) },
onClickGestureTyping = { navController.navigate(SettingsDestination.GestureTyping) },
+ onClickDataGathering = { navController.navigate(SettingsDestination.DataGathering) },
onClickAdvanced = { navController.navigate(SettingsDestination.Advanced) },
onClickAppearance = { navController.navigate(SettingsDestination.Appearance) },
onClickLanguage = { navController.navigate(SettingsDestination.Languages) },
@@ -93,6 +95,13 @@ fun SettingsNavHost(
composable(SettingsDestination.GestureTyping) {
GestureTypingScreen(onClickBack = ::goBack)
}
+ composable(SettingsDestination.DataGathering) {
+ GestureDataScreen(onClickBack = ::goBack)
+ }
+/* will be added as part of passive data gathering
+ composable(SettingsDestination.DataReview) {
+ ReviewScreen(onClickBack = ::goBack)
+ }*/
composable(SettingsDestination.Advanced) {
AdvancedSettingsScreen(onClickBack = ::goBack)
}
@@ -142,6 +151,8 @@ object SettingsDestination {
const val Preferences = "preferences"
const val Toolbar = "toolbar"
const val GestureTyping = "gesture_typing"
+ const val DataGathering = "data_gathering" // remove when data gathering phase is done (end of 2026 latest)
+ const val DataReview = "data_review" // remove when data gathering phase is done (end of 2026 latest)
const val Advanced = "advanced"
const val Debug = "debug"
const val Appearance = "appearance"
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/MainSettingsScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/MainSettingsScreen.kt
index 3d8af0c5a6..fe1be0b175 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/MainSettingsScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/MainSettingsScreen.kt
@@ -26,6 +26,8 @@ import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.initPreview
import helium314.keyboard.settings.preferences.Preference
import helium314.keyboard.settings.previewDark
+import helium314.keyboard.settings.screens.gesturedata.END_DATE_EPOCH_MILLIS
+import helium314.keyboard.settings.screens.gesturedata.TWO_WEEKS_IN_MILLIS
@Composable
fun MainSettingsScreen(
@@ -34,6 +36,7 @@ fun MainSettingsScreen(
onClickPreferences: () -> Unit,
onClickToolbar: () -> Unit,
onClickGestureTyping: () -> Unit,
+ onClickDataGathering: () -> Unit,
onClickAdvanced: () -> Unit,
onClickAppearance: () -> Unit,
onClickLanguage: () -> Unit,
@@ -78,6 +81,13 @@ fun MainSettingsScreen(
onClick = onClickGestureTyping,
icon = R.drawable.ic_settings_gesture
) { NextScreenIcon() }
+ // we don't even show the menu if data gathering phase ended more than 2 weeks ago
+ if (JniUtils.sHaveGestureLib && System.currentTimeMillis() < END_DATE_EPOCH_MILLIS + TWO_WEEKS_IN_MILLIS)
+ Preference(
+ name = stringResource(R.string.gesture_data_screen),
+ onClick = onClickDataGathering,
+ icon = R.drawable.ic_settings_gesture
+ ) { NextScreenIcon() }
Preference(
name = stringResource(R.string.settings_screen_correction),
onClick = onClickTextCorrection,
@@ -114,7 +124,7 @@ private fun PreviewScreen() {
initPreview(LocalContext.current)
Theme(previewDark) {
Surface {
- MainSettingsScreen({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {})
+ MainSettingsScreen({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {})
}
}
}
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/gesturedata/GestureDataScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/gesturedata/GestureDataScreen.kt
new file mode 100644
index 0000000000..ab07720dcf
--- /dev/null
+++ b/app/src/main/java/helium314/keyboard/settings/screens/gesturedata/GestureDataScreen.kt
@@ -0,0 +1,679 @@
+// SPDX-License-Identifier: GPL-3.0-only
+package helium314.keyboard.settings.screens.gesturedata
+
+import android.content.Context
+import android.view.inputmethod.InputMethodManager
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.BottomAppBar
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+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.draw.alpha
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.fromHtml
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.PlatformImeOptions
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.intl.LocaleList
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.android.inputmethod.latin.BinaryDictionary
+import helium314.keyboard.compat.locale
+import helium314.keyboard.keyboard.Keyboard
+import helium314.keyboard.latin.NgramContext
+import helium314.keyboard.latin.R
+import helium314.keyboard.latin.SingleDictionaryFacilitator
+import helium314.keyboard.latin.SuggestedWords
+import helium314.keyboard.latin.common.ComposedData
+import helium314.keyboard.latin.common.Links
+import helium314.keyboard.latin.common.LocaleUtils
+import helium314.keyboard.latin.common.LocaleUtils.constructLocale
+import helium314.keyboard.latin.dictionary.Dictionary
+import helium314.keyboard.latin.settings.Settings
+import helium314.keyboard.latin.utils.ChecksumCalculator
+import helium314.keyboard.latin.utils.DictionaryInfoUtils
+import helium314.keyboard.latin.utils.GestureDataDao
+import helium314.keyboard.latin.utils.ScriptUtils
+import helium314.keyboard.latin.utils.SubtypeLocaleUtils
+import helium314.keyboard.latin.utils.SubtypeSettings
+import helium314.keyboard.latin.utils.SuggestionResults
+import helium314.keyboard.latin.utils.UncachedInputMethodManagerUtils
+import helium314.keyboard.latin.utils.WordData
+import helium314.keyboard.latin.utils.dictTestImeOption
+import helium314.keyboard.latin.utils.gestureDataActiveFacilitator
+import helium314.keyboard.latin.utils.getExportedActiveDeletionCount
+import helium314.keyboard.latin.utils.getSecondaryLocales
+import helium314.keyboard.latin.utils.locale
+import helium314.keyboard.settings.DropDownField
+import helium314.keyboard.settings.NextScreenIcon
+import helium314.keyboard.settings.Theme
+import helium314.keyboard.settings.dialogs.ConfirmationDialog
+import helium314.keyboard.settings.dialogs.InfoDialog
+import helium314.keyboard.settings.dialogs.ThreeButtonAlertDialog
+import helium314.keyboard.settings.initPreview
+import helium314.keyboard.settings.isWideScreen
+import helium314.keyboard.settings.previewDark
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.io.File
+import java.text.DateFormat
+import java.util.Date
+import java.util.Locale
+import kotlin.collections.plus
+import kotlin.random.Random
+
+// functionality for gesture data gathering as part of the NLNet Project https://nlnet.nl/project/GestureTyping/
+// will be removed once the project is finished
+/**
+ * Simple "settings" screen that shows up when gesture typing is enabled.
+ * Allows "active data gathering", which is input of gesture typing data,
+ * and getting results in a file / sending as mail.
+ * This mode is rather simple: the user chooses a dictionary, and get a
+ * random word. After swiping this word it gets stored along with necessary
+ * information to recreate the input and some additional data.
+ *
+ * Will allow enabling "passive data gathering" later, which stores most of
+ * the words entered using gesture typing. Here the user needs the ability
+ * review and redact the data before sending, and additionally exclude some
+ * words and apps from passive gathering.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GestureDataScreen(
+ onClickBack: () -> Unit,
+) {
+ val ctx = LocalContext.current
+ val useWideLayout = isWideScreen()
+ val dao = GestureDataDao.getInstance(ctx)!!
+
+ // ideally we'd move all the active gathering stuff into a separate (non-local) function,
+ // but either it has issues with the floating button positioning (if they are in the function)
+ // or the keyboard flashes during recomposition if they are outside the function
+ var wordFromDict by remember { mutableStateOf(null) } // some word from the dictionary
+ var lastData by remember { mutableStateOf(null) }
+ var sessionWordCount by remember { mutableIntStateOf(0) }
+ var dbActiveWordCount by remember { mutableIntStateOf(dao.count(activeMode = true)) }
+ var showUploadDialog by rememberSaveable { mutableStateOf(true) }
+ var showEndDialog by rememberSaveable { mutableStateOf(true) }
+ val focusRequester = remember { FocusRequester() }
+ val keyboard = LocalSoftwareKeyboardController.current
+ val words = remember { mutableListOf>() }
+ val scope = rememberCoroutineScope()
+ fun nextWord(save: Boolean) {
+ if (!save) {
+ lastData = null
+ }
+ lastData?.let { scope.launch {
+ ++sessionWordCount
+ it.save(ctx)
+ } }
+ wordFromDict = getRandomWord(words)
+ lastData = null
+ // reset the data
+ focusRequester.requestFocus()
+ keyboard?.show()
+ }
+ if (showEndDialog) {
+ if (System.currentTimeMillis() < END_DATE_EPOCH_MILLIS - TWO_WEEKS_IN_MILLIS)
+ showEndDialog = false
+ val endDate = DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(END_DATE_EPOCH_MILLIS))
+ if (System.currentTimeMillis() > END_DATE_EPOCH_MILLIS) {
+ val message = stringResource(R.string.gesture_data_ended, endDate)
+ val infos = dao.filterInfos(limit = 10000) // export no more than 10k words at once due to possibly hitting mail size limits
+ ThreeButtonAlertDialog(
+ onDismissRequest = onClickBack,
+ content = {
+ Column {
+ Text(message)
+ if (infos.isNotEmpty())
+ ShareGestureData(infos.map { it.id })
+ }
+ },
+ cancelButtonText = stringResource(android.R.string.ok),
+ onConfirmed = { },
+ confirmButtonText = null
+ )
+ } else {
+ val message = stringResource(R.string.gesture_data_ends_at, endDate)
+ InfoDialog(message) { showEndDialog = false }
+ }
+
+ }
+ if (showUploadDialog) {
+ if (dao.count() < 10000)
+ showUploadDialog = false
+ InfoDialog(stringResource(R.string.gesture_data_much_data)) { showUploadDialog = false }
+ }
+ @Composable fun activeGathering() {
+ val availableDicts = remember { getAvailableDictionaries(ctx) }
+ val currentLocale = Settings.getValues().mLocale
+ var dict by remember { mutableStateOf(
+ LocaleUtils.getBestMatch(currentLocale, availableDicts) { it.locale }
+ ) }
+ LaunchedEffect(wordFromDict) { focusRequester.requestFocus() }
+ val suggestionLogger = remember {
+ object : SingleDictionaryFacilitator.Companion.SuggestionLogger {
+ override fun onNewSuggestions(
+ suggestions: SuggestionResults,
+ composedData: ComposedData,
+ ngramContext: NgramContext,
+ keyboard: Keyboard,
+ inputStyle: Int
+ ) {
+ if (!composedData.mIsBatchMode || inputStyle != SuggestedWords.INPUT_STYLE_TAIL_BATCH) return
+ val target = wordFromDict ?: return
+ val newData = WordData(target, suggestions, composedData, ngramContext, keyboard, inputStyle, true)
+ if (suggestions.any { it.mWord == target && it.mScore >= 0 }) { // just not negative should be fine
+ scope.launch {
+ ++sessionWordCount
+ newData.save(ctx)
+ }
+ nextWord(false)
+ } else {
+ lastData = newData // use the old flow with button
+ }
+ }
+ }
+ }
+ LaunchedEffect(dict) {
+ val dict = dict ?: return@LaunchedEffect
+ gestureDataActiveFacilitator?.closeDictionaries()
+ gestureDataActiveFacilitator = SingleDictionaryFacilitator(dict.getDictionary(ctx))
+ gestureDataActiveFacilitator?.suggestionLogger = suggestionLogger
+ lastData = null
+ wordFromDict = null
+ scope.launch(Dispatchers.Default) {
+ words.clear()
+ dict.addWords(ctx, words)
+ }
+ scope.launch(Dispatchers.Default) {
+ delay(500)
+ var i = 0
+ while (words.isEmpty() && i++ < 20)
+ delay(50)
+ // at least a few words should be loaded now
+ nextWord(false)
+ }
+ }
+
+ @Composable fun dictsBox() {
+ Box { // without the box the menu appears at the wrong position
+ DropDownField(availableDicts, dict, { dict = it }) {
+ val locale = it?.locale?.getDisplayName(LocalConfiguration.current.locale())
+ val internal = if (it?.internal == true)
+ " (${stringResource(R.string.internal_dictionary_summary)})"
+ else ""
+ Text(locale?.let { loc -> "$loc$internal" } ?: stringResource(R.string.dictionary_load_error))
+ }
+ }
+ }
+ @Composable fun ColumnScope.texts() {
+ val imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ val text = when {
+ !UncachedInputMethodManagerUtils.isThisImeCurrent(ctx, imm) -> "please switch to HeliBoard"
+ else -> stringResource(R.string.gesture_data_please_type)
+ }
+ if (useWideLayout) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = text,
+ modifier = Modifier.alpha(if (wordFromDict == null) 0.5f else 1f)
+ )
+ CompositionLocalProvider(
+ LocalTextStyle provides MaterialTheme.typography.titleLarge
+ ) {
+ Text(
+ text = wordFromDict ?: "",
+ modifier = Modifier.fillMaxWidth(0.4f).padding(vertical = 10.dp),
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ } else {
+ Text(
+ text = text,
+ modifier = Modifier.alpha(if (wordFromDict == null) 0.5f else 1f)
+ )
+ CompositionLocalProvider(
+ LocalTextStyle provides MaterialTheme.typography.titleLarge
+ ) {
+ Text(
+ text = wordFromDict ?: "",
+ modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ }
+ if (!useWideLayout)
+ dictsBox()
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Column {
+ texts()
+ val exportedAndDeletedCount by remember { mutableIntStateOf(getExportedActiveDeletionCount(ctx)) }
+ val oldActiveWords by remember {
+ sessionWordCount = 0
+ dbActiveWordCount = dao.count(activeMode = true)
+ mutableIntStateOf(dbActiveWordCount + exportedAndDeletedCount)
+ }
+ Text(stringResource(R.string.gesture_data_active_count, sessionWordCount, sessionWordCount + oldActiveWords, exportedAndDeletedCount))
+ Box(Modifier.size(1.dp)) { // box hides the field, but we can still interact with it
+ TextField(
+ value = TextFieldValue(),
+ enabled = wordFromDict != null,
+ onValueChange = { },
+ keyboardOptions = KeyboardOptions(
+ platformImeOptions = PlatformImeOptions(privateImeOptions = dictTestImeOption),
+ imeAction = ImeAction.Next,
+ hintLocales = dict?.let { LocaleList(it.locale.toLanguageTag()) }
+ ),
+ keyboardActions = KeyboardActions { nextWord(false) },
+ modifier = Modifier.focusRequester(focusRequester),
+ )
+ }
+ }
+ }
+ if (useWideLayout)
+ dictsBox()
+ }
+
+ val scrollState = rememberScrollState()
+ var activeGathering by remember { mutableStateOf(false) }
+ Scaffold(
+ contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
+ bottomBar = { BottomBar(sessionWordCount + dbActiveWordCount > 0) { dbActiveWordCount = dao.count(activeMode = true) } }
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .verticalScroll(scrollState)
+ .padding(horizontal = 12.dp)
+ .then(Modifier.padding(innerPadding)),
+ ) {
+ var showActiveInfoDialog by remember { mutableStateOf(false) }
+ var showInfoDialog by remember { mutableStateOf(false) }
+ var showPrivacyDialog by remember { mutableStateOf(false) }
+ TopAppBar(
+ title = { Text(stringResource(R.string.gesture_data_screen)) },
+ navigationIcon = {
+ IconButton(onClick = { if (activeGathering) activeGathering = false else onClickBack() }) {
+ Icon(
+ painterResource(R.drawable.ic_arrow_back),
+ stringResource(R.string.spoken_description_action_previous)
+ )
+ }
+ },
+ )
+ BackHandler(enabled = activeGathering) { activeGathering = false }
+ if (activeGathering) { // AnimatedVisibility results in buggy behavior for some reason
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ ButtonWithText(stringResource(R.string.gesture_data_how_to_use)) {
+ showActiveInfoDialog = true
+ }
+ ButtonWithText(stringResource(R.string.gesture_data_active_stop)) {
+ activeGathering = false
+ }
+ }
+ activeGathering()
+ }
+ AnimatedVisibility(!activeGathering) {
+ // this part is hidden in active gathering mode because in active mode
+ // neither the keyboard nor the floating buttons (!) should cover any text
+ Column {
+ ButtonWithText(stringResource(R.string.gesture_data_info), Modifier.fillMaxWidth()) {
+ showInfoDialog = true
+ }
+ ButtonWithText(stringResource(R.string.gesture_data_privacy), Modifier.fillMaxWidth()) {
+ showPrivacyDialog = true
+ }
+ Spacer(Modifier.height(12.dp))
+ HorizontalDivider()
+ ButtonWithText(
+ stringResource(R.string.gesture_data_active_start),
+ Modifier.fillMaxWidth(),
+ System.currentTimeMillis() < END_DATE_EPOCH_MILLIS - TWO_WEEKS_IN_MILLIS // disabled when close to end
+ ) {
+ activeGathering = true
+ lastData = null
+ wordFromDict = null
+ }
+ ButtonWithText(stringResource(R.string.gesture_data_how_to_use), Modifier.fillMaxWidth()) {
+ showActiveInfoDialog = true
+ }
+ }
+ }
+ if (showInfoDialog)
+ InfoDialog(AnnotatedString.fromHtml(
+ stringResource(R.string.gesture_data_description,
+ DateFormat.getDateInstance(DateFormat.LONG).format(Date(END_DATE_EPOCH_MILLIS)))
+ ) + AnnotatedString("\n\n" + stringResource(R.string.gesture_data_description_modes))) { showInfoDialog = false }
+ if (showPrivacyDialog)
+ InfoDialog(stringResource(R.string.gesture_data_description_privacy)) { showPrivacyDialog = false }
+ if (showActiveInfoDialog)
+ InfoDialog(AnnotatedString.fromHtml(stringResource(R.string.gesture_data_active_description, Links.DICTIONARY_URL))) { showActiveInfoDialog = false }
+ Spacer(Modifier.height(12.dp))
+ // PassiveGathering & Review are not finished and will be completed + enabled later
+/*
+ HorizontalDivider()
+ PassiveGathering()
+ Spacer(Modifier.height(12.dp))
+ HorizontalDivider()
+ // maybe move the review screen content in here if we have enough space (but landscape mode will be bad)
+ TextButton(onClick = { SettingsDestination.navigateTo(SettingsDestination.DataReview) }) {
+ Text(stringResource(R.string.gesture_data_review_screen_title))
+ }
+ */
+ }
+ }
+ // showing at top left in preview, but correctly on device
+ if (lastData != null && activeGathering)
+ ExtendedFloatingActionButton(
+ onClick = { nextWord(true) },
+ // doesn't look good with the long text
+ text = { Text(stringResource(R.string.gesture_data_next_save, lastData?.suggestions?.firstOrNull()?.word.toString())) },
+ icon = { NextScreenIcon() },
+ modifier = Modifier
+ .wrapContentSize(Alignment.BottomEnd)
+ .padding(all = 12.dp)
+ .then(Modifier.safeDrawingPadding())
+ .fillMaxWidth(if (useWideLayout) 0.3f else 0.5f)
+ )
+ if (wordFromDict != null && activeGathering)
+ ExtendedFloatingActionButton(
+ onClick = { nextWord(false) },
+ text = { Text(stringResource(R.string.gesture_data_next)) },
+ icon = { Icon(painter = painterResource(R.drawable.ic_ime_switcher), stringResource(R.string.gesture_data_next)) },
+ modifier = Modifier
+ .wrapContentSize(Alignment.BottomStart)
+ .padding(all = 12.dp)
+ .then(Modifier.safeDrawingPadding())
+ )
+}
+
+@Composable
+private fun BottomBar(hasWords: Boolean, onDeleted: () -> Unit) {
+ var showExportDialog by remember { mutableStateOf(false) }
+ var showDeleteDialog by remember { mutableStateOf(false) }
+ val dao = GestureDataDao.getInstance(LocalContext.current)!!
+ BottomAppBar(
+ actions = {
+ Row(
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ IconButton(
+ onClick = { showDeleteDialog = true},
+ enabled = hasWords
+ ) {
+ Icon(
+ painterResource(R.drawable.ic_bin_rounded),
+ stringResource(R.string.delete),
+ Modifier.size(30.dp)
+ )
+ }
+ IconButton(
+ onClick = { showExportDialog = true},
+ enabled = hasWords
+ ) {
+ Icon(
+ painterResource(R.drawable.ic_share),
+ "share",
+ Modifier.size(30.dp)
+ )
+ }
+ }
+ }
+ )
+ if (showExportDialog) {
+ val notExportedCount = dao.count(activeMode = true, exported = false)
+ val totalCount = dao.count(activeMode = true)
+ var shareAll by remember { mutableStateOf(null) }
+ ThreeButtonAlertDialog(
+ onDismissRequest = { showExportDialog = false },
+ content = {
+ if (shareAll == null) {
+ Column {
+ ButtonWithText(stringResource(R.string.gesture_data_share_new, notExportedCount), enabled = notExportedCount > 0) {
+ shareAll = false
+ }
+ ButtonWithText(stringResource(R.string.gesture_data_share_all, totalCount), enabled = totalCount > 0) {
+ shareAll = true
+ }
+ }
+ } else {
+ val toShare = dao.filterInfos(
+ activeMode = true,
+ exported = if (shareAll == true) null else false,
+ limit = 10000 // export no more than 10k words at once due to possibly hitting mail size limits
+ )
+ Column { ShareGestureData(toShare.map { it.id }) }
+ }
+ },
+ cancelButtonText = stringResource(R.string.dialog_close),
+ onConfirmed = { },
+ confirmButtonText = null
+ )
+ }
+ if (showDeleteDialog) {
+ val infos = dao.filterInfos(activeMode = true)
+ val exportedCount = infos.count { it.exported }
+ val nonExportedCount = infos.size - exportedCount
+ var showConfirmDialog by remember { mutableStateOf(null) }
+ ThreeButtonAlertDialog(
+ onDismissRequest = { showDeleteDialog = false },
+ content = {
+ Column {
+ Text(stringResource(R.string.gesture_data_delete_dialog, nonExportedCount, exportedCount))
+ ButtonWithText(stringResource(R.string.gesture_data_delete_dialog_submitted, exportedCount)) { showConfirmDialog = "exported" }
+ ButtonWithText(stringResource(R.string.gesture_data_delete_dialog_all, exportedCount + nonExportedCount)) { showConfirmDialog = "all" }
+ }
+ },
+ cancelButtonText = stringResource(R.string.dialog_close),
+ onConfirmed = { },
+ confirmButtonText = null,
+ )
+ if (showConfirmDialog != null) {
+ val ctx = LocalContext.current
+ ConfirmationDialog(
+ onDismissRequest = { showDeleteDialog = false },
+ onConfirmed = {
+ val ids = dao.filterInfos(activeMode = true).map { it.id }
+ dao.delete(ids, showConfirmDialog != "all", ctx)
+ onDeleted()
+ },
+ content = {
+ Text(stringResource(
+ R.string.delete_confirmation,
+ if (showConfirmDialog == "all") (exportedCount + nonExportedCount) else exportedCount
+ ))
+ }
+ )
+ }
+ }
+}
+
+// the text buttons look ugly, try something different
+@Composable
+fun ButtonWithText(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) {
+ Button(onClick, modifier, enabled) {
+ Text(text)
+ }
+}
+
+// we only check dictionaries for enabled locales (main + secondary)
+private fun getAvailableDictionaries(context: Context): List {
+ val allowedHashes = context.assets.open("known_dict_hashes.txt")
+ .use { it.reader().readLines() }.filterNot { it.isBlank() || it.startsWith("#") }
+ val locales = SubtypeSettings.getEnabledSubtypes(true).flatMap {
+ getSecondaryLocales(it.extraValue) + it.locale()
+ }
+ val languages = locales.mapTo(hashSetOf()) { it.language }
+ val cached = DictionaryInfoUtils.getCacheDirectories(context)
+ .filter { it.name.constructLocale().language in languages }
+ .mapNotNull { dir -> dir.listFiles()?.filter {
+ it.name.startsWith(DictionaryInfoUtils.DEFAULT_MAIN_DICT)
+ } }.flatten().map { CacheDictWithInfo(it) }.filter { it.hash in allowedHashes }
+ val assets = DictionaryInfoUtils.getAssetsDictionaryList(context).orEmpty()
+ .filter { it.substringAfter("_").substringBefore(".dict").constructLocale().language in languages }
+ .map { AssetsDictWithInfo(it, context) }
+ .filter { dict -> cached.none { it.hash == dict.hash } && dict.hash in allowedHashes }
+ return cached + assets
+}
+
+private interface DictWithInfo {
+ val hash: String
+ val locale: Locale
+ val internal: Boolean
+ fun getDictionary(context: Context): BinaryDictionary
+ // not actually suspending, but makes clear that it shouldn't be called on UI thread (because it's slow)
+ suspend fun addWords(context: Context, words: MutableList>) = getDictionary(context).addWords(words)
+}
+
+private class CacheDictWithInfo(private val file: File): DictWithInfo {
+ override val locale = (DictionaryInfoUtils.getDictionaryFileHeaderOrNull(file)?.mLocaleString ?: SubtypeLocaleUtils.NO_LANGUAGE)
+ .constructLocale()
+ override val hash = ChecksumCalculator.checksum(file) ?: ""
+ override val internal = !file.name.endsWith(DictionaryInfoUtils.USER_DICTIONARY_SUFFIX)
+ override fun getDictionary(context: Context) =
+ BinaryDictionary(file.absolutePath, 0, file.length(), false, locale, Dictionary.TYPE_MAIN, false)
+}
+
+private class AssetsDictWithInfo(private val name: String, context: Context): DictWithInfo {
+ override val internal = true
+ override val locale = DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(name)
+ override val hash = context.assets.open("dicts${File.separator}$name").use { ChecksumCalculator.checksum(it) } ?: ""
+ override fun getDictionary(context: Context): BinaryDictionary {
+ val file = DictionaryInfoUtils.extractAssetsDictionary(name, locale, context)!!
+ return BinaryDictionary(file.absolutePath, 0, file.length(), false, locale, Dictionary.TYPE_MAIN, false)
+ }
+}
+
+private fun BinaryDictionary.addWords(words: MutableList>) {
+ var token = 0
+ val hasCases = mLocale?.let { ScriptUtils.scriptSupportsUppercase(it) } ?: true
+ var cumulativeWeight = /*words.lastOrNull()?.second ?:*/ 0L
+ var added = false
+ do {
+ val result = getNextWordProperty(token)
+ val word = result.mWordProperty.mWord
+ if (!result.mWordProperty.mIsNotAWord
+ && word.length > 1
+ && !(result.mWordProperty.mIsPossiblyOffensive && Settings.getValues().mBlockPotentiallyOffensive)
+ && result.mWordProperty.probability > 2 // some minimum value, as there are too many unknown / rare words down there
+ && (!hasCases || word.uppercase() != word)
+ ) {
+ // probability actually is something like log or very high root of actual word frequency
+ // we use power of 4 to shift the probabilities in favor of more frequent words, so users mostly see relatively common words, but aren't bored by tons of very common words
+ cumulativeWeight += result.mWordProperty.probability * result.mWordProperty.probability * result.mWordProperty.probability * result.mWordProperty.probability
+ if (added && words.isEmpty())
+ return // crappy workaround for having 2 merged dictionaries when switching dicts while one is still loading
+ words.add(word to cumulativeWeight)
+ added = true
+ }
+ token = result.mNextToken
+ } while (token != 0)
+}
+
+// words will be added to the list while we're choosing -> ignore the new words
+// list may get cleared while we're choosing -> return null in that case
+private fun getRandomWord(words: MutableList>): String? {
+ if (words.isEmpty()) return null
+ val maxIndex = words.lastIndex
+ val lastCumWeight = words.getOrNull(maxIndex)?.second ?: return null
+ val random = Random.nextLong(lastCumWeight + 1)
+ return words.searchFirstExceedingScore(random)
+}
+
+// modified Kotlin binary search for cumulative weights
+private fun List>.searchFirstExceedingScore(scoreToExceed: Long, fromIndex: Int = 0, toIndex: Int = lastIndex): T? {
+ var low = fromIndex
+ var high = toIndex
+
+ while (low <= high) {
+ val mid = (low + high).ushr(1) // safe from overflows
+ val midVal = getOrNull(mid) ?: return null
+ val scoreBeforeMid = getOrNull(mid - 1)?.second ?: 0
+
+ // we want midVal to be the lowest value larger than scoreToExceed, i.e. scoreBeforeMid <= scoreToExceed < midVal.score
+ if (scoreBeforeMid <= scoreToExceed) {
+ if (midVal.second > scoreToExceed)
+ return midVal.first
+ low = mid + 1
+ } else {
+ high = mid - 1
+ }
+ }
+ return null
+}
+
+const val END_DATE_EPOCH_MILLIS = 1796079600000L // Dec 1st 2026
+const val TWO_WEEKS_IN_MILLIS = 14L * 24 * 3600 * 1000
+
+@Preview
+@Composable
+private fun Preview() {
+ initPreview(LocalContext.current)
+ Theme(previewDark) {
+ Surface {
+ GestureDataScreen { }
+ }
+ }
+}
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/gesturedata/Share.kt b/app/src/main/java/helium314/keyboard/settings/screens/gesturedata/Share.kt
new file mode 100644
index 0000000000..27943e9a3f
--- /dev/null
+++ b/app/src/main/java/helium314/keyboard/settings/screens/gesturedata/Share.kt
@@ -0,0 +1,272 @@
+// SPDX-License-Identifier: GPL-3.0-only
+package helium314.keyboard.settings.screens.gesturedata
+
+import android.content.ClipData
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.CancellationSignal
+import android.os.ParcelFileDescriptor
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.result.ActivityResult
+import androidx.annotation.RequiresApi
+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.setValue
+import androidx.compose.ui.platform.ClipEntry
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.core.content.FileProvider
+import androidx.core.net.toUri
+import helium314.keyboard.latin.BuildConfig
+import helium314.keyboard.latin.R
+import helium314.keyboard.latin.utils.GestureDataDao
+import helium314.keyboard.latin.utils.getActivity
+import helium314.keyboard.settings.dialogs.ConfirmationDialog
+import helium314.keyboard.settings.filePicker
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.io.File
+import java.io.FileInputStream
+import java.io.OutputStream
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+// functionality for gesture data gathering as part of the NLNet Project https://nlnet.nl/project/GestureTyping/
+// will be removed once the project is finished
+
+@Composable
+fun ShareGestureData(ids: List) {
+ val ctx = LocalContext.current
+ val dao = GestureDataDao.getInstance(ctx)!!
+ val hasData = !dao.isEmpty() // no need to update if we have it in a dialog
+ val getDataPicker = getData(ids)
+ var exportStarted by remember { mutableStateOf(false) }
+ var exportDone by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+ LaunchedEffect(exportStarted) {
+ if (exportStarted) {
+ // wait until exported data is marked, then offer to delete
+ gestureIdsBeingExported = ids
+ scope.launch {
+ while (gestureIdsBeingExported != null) {
+ delay(50)
+ }
+ exportDone = true
+ }
+ }
+ }
+
+ if (!exportDone) {
+ // share file, but only to mail apps
+ ButtonWithText(
+ stringResource(R.string.gesture_data_send_mail),
+ enabled = hasData && Intent(Intent.ACTION_SENDTO)
+ .apply { data = "mailto:".toUri() }.resolveActivity(ctx.packageManager) != null
+ ) {
+ createZipFile(ctx, ids)
+ if (zippedDataPath.isNotEmpty()) {
+ exportStarted = true
+ ctx.startActivity(createSendIntentChooser(ctx))
+ }
+ }
+
+ // get file
+ ButtonWithText(stringResource(R.string.gesture_data_get_data), enabled = hasData) {
+ getDataPicker.launch(getDataIntent)
+ exportStarted = true
+ }
+
+ // copy mail address to clipboard, in case user doesn't use the mail intent
+ val clip = LocalClipboard.current
+ ButtonWithText(stringResource(R.string.gesture_data_copy_mail)) {
+ scope.launch { clip.setClipEntry(ClipEntry(ClipData.newPlainText("mail address", deobfuscateEmail(ctx)))) }
+ }
+ Text(stringResource(R.string.gesture_data_mail_use))
+ } else {
+ // this deletes the data in the dialog, but we should also have a way of deleting previously exported data
+ var confirmDelete by remember { mutableStateOf(false) }
+ ButtonWithText(stringResource(R.string.gesture_data_delete_dialog_submitted, ids.size), enabled = hasData) {
+ confirmDelete = true
+ }
+ if (confirmDelete) {
+ ConfirmationDialog(
+ onDismissRequest = { confirmDelete = false },
+ onConfirmed = { dao.delete(ids, true, ctx) },
+ content = {
+ Text(stringResource(R.string.delete_confirmation, ids.size))
+ }
+ )
+ }
+ }
+}
+
+private val getDataIntent = Intent(Intent.ACTION_CREATE_DOCUMENT)
+ .addCategory(Intent.CATEGORY_OPENABLE)
+ .putExtra(Intent.EXTRA_TITLE, "gesture_data_${SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Calendar.getInstance().time)}.zip")
+ .setType("application/zip")
+
+private fun deobfuscateEmail(context: Context): String {
+ return context.getString(R.string.gesture_data_mail)
+ .map { c -> Char(c.code-2) }
+ .joinToString(separator = "")
+}
+
+private fun createSendIntentChooser(context: Context): Intent {
+ // targets all apps that accept this kind of intent
+ val shareFileIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "application/zip"
+ putExtra(Intent.EXTRA_EMAIL, arrayOf(deobfuscateEmail(context)))
+ putExtra(Intent.EXTRA_SUBJECT, MAIL_SUBJECT)
+ putExtra(Intent.EXTRA_TEXT, context.getString(R.string.gesture_data_email_body))
+ putExtra(Intent.EXTRA_STREAM, getZipFileUri(context))
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+
+ val chooser = Intent.createChooser(shareFileIntent, "share it").apply {
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+
+ // if you are on an android older than N... you will have to just find your email in the list of apps!
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ filterIntentToOnlyIncludeEmailApps(context, shareFileIntent)
+ }
+ }
+ return chooser
+}
+
+@RequiresApi(Build.VERSION_CODES.N)
+private fun Intent.filterIntentToOnlyIncludeEmailApps(context: Context, intentToFilter: Intent) {
+ val sendMailIntent = Intent(Intent.ACTION_SENDTO).apply { data = "mailto:".toUri() }
+ val mailTargets = context.packageManager.queryIntentActivities(sendMailIntent, 0)
+ val shareTargets = context.packageManager.queryIntentActivities(intentToFilter, 0)
+
+ // so now we get the shareTargets that are not mailTargets and create ComponentNames...
+ val mailTargetPackages = mailTargets.map { it.activityInfo.packageName }
+ val nonMailShareTargets = shareTargets.filterNot { it.activityInfo.packageName in mailTargetPackages }
+ val nonMailShareTargetComponentNames = nonMailShareTargets.map { ComponentName(it.activityInfo.packageName, it.activityInfo.name) }
+
+ // ... because the chooser only allows to exclude components, but has no multi-component include functionality
+ putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, nonMailShareTargetComponentNames.toTypedArray())
+}
+
+private fun getZipFileUri(context: Context) : Uri =
+ FileProvider.getUriForFile(
+ context,
+ context.getString(R.string.gesture_data_provider_authority),
+ getGestureZipFile(context))
+
+@Composable
+private fun getData(ids: List): ManagedActivityResultLauncher {
+ val ctx = LocalContext.current
+ // check if file exists and not size 0, otherwise don't even offer the button
+ return filePicker { uri ->
+ val file = getGestureDataFile(ctx)
+ val dao = GestureDataDao.getInstance(ctx) ?: return@filePicker
+ val data = dao.getJsonData(ids)
+ file.writeText(makeJsonArrayFromEntries(data))
+ ctx.getActivity()?.contentResolver?.openOutputStream(uri)?.use {
+ os -> writeOutputToZipStream(file, os)
+ }
+ dao.markAsExported(ids, ctx)
+ gestureIdsBeingExported = null
+ }
+}
+
+private fun createZipFile(ctx: Context, ids: List) : File {
+ zippedDataPath = ""
+ val jsonFile = getGestureDataFile(ctx)
+ val dao = GestureDataDao.getInstance(ctx)!!
+ val data = dao.getJsonData(ids)
+ jsonFile.writeText(makeJsonArrayFromEntries(data))
+ val zipFile = getGestureZipFile(ctx)
+ zipFile.delete()
+ zipFile.outputStream().use {
+ os -> writeOutputToZipStream(jsonFile, os)
+ }
+ zippedDataPath = zipFile.absolutePath
+ return zipFile
+}
+
+private fun writeOutputToZipStream(jsonFile: File, os: OutputStream) {
+ val zipStream = ZipOutputStream(os)
+ zipStream.setLevel(9)
+ val fileStream = FileInputStream(jsonFile).buffered()
+ zipStream.putNextEntry(ZipEntry(jsonFile.name))
+ fileStream.copyTo(zipStream, 1024)
+ fileStream.close()
+ zipStream.closeEntry()
+ zipStream.close()
+}
+
+private fun makeJsonArrayFromEntries(entries: List): String
+ = buildString {
+ val allButLast = entries.subList(0, entries.lastIndex)
+
+ append("[\n")
+ allButLast.forEach {
+ append(it)
+ append(",\n")
+ }
+ append(entries.last())
+ append("\n]")
+}
+
+private fun getGestureZipFile(context: Context): File = fileGetDelegate(context, context.getString(R.string.gesture_data_zip))
+
+private fun getGestureDataFile(context: Context): File = fileGetDelegate(context, context.getString(R.string.gesture_data_json))
+
+private fun fileGetDelegate(context: Context, filename: String): File {
+ // ensure folder exists
+ val dir = File(context.filesDir, context.getString(R.string.gesture_data_directory))
+ if (!dir.exists()) {
+ dir.mkdirs()
+ }
+
+ return File(dir, filename)
+}
+
+// necessary for giving the mail app access to an internal file
+// overrides to check when data is actually read (i.e. chooser not cancelled)
+class GestureFileProvider : FileProvider() {
+ override fun openFile(uri: Uri, mode: String, signal: CancellationSignal?): ParcelFileDescriptor? {
+ try {
+ return super.openFile(uri, mode, signal)
+ } finally {
+ onFileOpened()
+ }
+ }
+
+ override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
+ try {
+ return super.openFile(uri, mode)
+ } finally {
+ onFileOpened()
+ }
+ }
+
+ private fun onFileOpened() {
+ val ctx = context
+ val ids = gestureIdsBeingExported
+ if (ctx != null && ids != null) {
+ GestureDataDao.getInstance(ctx)?.markAsExported(ids, ctx)
+ }
+ gestureIdsBeingExported = null
+ }
+}
+
+private var gestureIdsBeingExported: List? = null
+
+private var zippedDataPath = "" // set after writing the file
+
+private const val MAIL_SUBJECT = "HeliBoard ${BuildConfig.VERSION_NAME} gesture data"
diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml
new file mode 100644
index 0000000000..9558d97512
--- /dev/null
+++ b/app/src/main/res/drawable/ic_share.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_sort_alphabetically.xml b/app/src/main/res/drawable/ic_sort_alphabetically.xml
new file mode 100644
index 0000000000..2c3750db67
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sort_alphabetically.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_sort_chronologically.xml b/app/src/main/res/drawable/ic_sort_chronologically.xml
new file mode 100644
index 0000000000..33a6915ca4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sort_chronologically.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/app/src/main/res/values/gesture_data.xml b/app/src/main/res/values/gesture_data.xml
new file mode 100644
index 0000000000..de6c7786fe
--- /dev/null
+++ b/app/src/main/res/values/gesture_data.xml
@@ -0,0 +1,8 @@
+
+
+ helium314.keyboard.provider
+ gesture_data
+ heliboard_data.gestures.json
+ gesture_data.zip
+ jgnkdqctf0iguvwtg0fcvcBro0og
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index aa378e4625..bf59d2d6bd 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -4,7 +4,10 @@
modified
SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only
-->
-
+
HeliBoard Spell Checker
@@ -915,4 +918,138 @@ New dictionary:
Custom subtype
Landscape
+
+
+
+
+
+ Gesture data gathering
+
+
+ Welcome to the gesture data gathering screen!<br/><br/>
+ This is part of an <a href=https://nlnet.nl/project/GestureTyping>NLnet-funded project</a> striving to produce a fully open source gesture typing library capable of replacing the closed source Google library.<br/>
+ The result is planned to be a simple replacement for AOSP-based keyboards, but also usable with other keyboards, e.g. FlorisBoard.<br/><br/>
+ For developing the library, it is tremendously helpful to have detailed gesture data in different languages and by different users available.<br/>
+ By gathering and submitting gesture typing data you can contribute to an open set of gesture data that will used for library development. The dataset will be published under a CC BY-SA 4.0 license some time after the data gathering phase is finished on %s.
+ Once you have collected data, you can submit it using the share icon on the bottom of this screen.
+
+
+ What\'s this?
+
+
+ Currently there is only the "active gathering" mode, where you are shown a specific word, and enter this word using gesture typing.\n
+ Later a "passive gathering" mode will be added, where you can provide samples from your normal gesture typing from other apps.
+ Due to the sensitivity of this approach, this will be opt-in, configurable, and have a review screen for the data you\'re about to submit.
+
+
+
+ Privacy\n\n
+ The data you submit consists of target word, keyboard layout and dimensions, gesture (times and coordinates), suggestions by the current library, hash of the used dictionaries, app version, data gathering mode, whether the gesture typing library is the "default" Google library, and an ID to go with each gesture.\n
+ Submitted data will be downloaded and deleted from the ProtonMail mailbox where it is stored at our earliest convenience.
+ After the gesture data gathering phase has ended, the accumulated data set from all users will be published under the Creative Commons BY-SA 4.0 license.\n\n
+
+ As part of trying to protect your privacy, you won\'t be listed explicitly as an author. Your credit will be as \"The HeliBoard Gesture Data Project Contributors.\" It should not be possible to connect the data you generate in this screen to your identity. However, it might be possible to determine which gestures were made by the same person.
+ This can be done e.g. from a custom keyboard layout, keyboard dimensions, language, or individual gesturing style.\n\n
+
+
+ Privacy
+
+ Share gesture data file
+
+ Clear gesture data
+
+ Send data via email
+
+ Copy email address
+
+ Note that the email address is intended for data submissions, you may not get fast or consistent replies.
+
+ Please gesture type:
+
+ Got "%s". Click if you\'re sure you gestured correctly
+
+ Skip word
+
+ %1$s words in this session, %2$s words total (%3$s already submitted or deleted)
+
+ Review & share gesture data
+
+ %s words
+
+ Date range
+
+ You have a lot of unsubmitted data. Please submit and then remove the old data.
+
+ How to use
+
+ Tap on \"Start active gathering\", optionally switch the dictionary, and start gesture typing the displayed word.<br/>
+ If your gesture matches the word, the data is saved and the next word is displayed. Otherwise you can retry, skip the word, or confirm you gestured correctly.<br/>
+ You can only use dictionaries that are built into the app or available in the <a href=%s>dictionaries repository</a>, no other words will be used.
+
+
+ Start active gathering
+
+ Stop active gathering
+
+ Gesture data gathering phase will end at %s. Please submit your data before that date.
+
+ Gesture data gathering phase ended at %s. You can share your existing data, but not add anything new.
+
+ Share all %s words
+
+ Share %s not previously submitted words
+
+ You have data for %1$s new and %2$s already shared words.
+
+ Delete all %s words
+
+ Are you sure? This will delete %s words. Make sure your shared date is submitted!
+
+ Delete %s submitted words
+
+ You have more than %s words waiting to be submitted
+
+ IMPORTANT:\n\n
+ Hey there!\n
+ As a reminder, this data is going to be publicly shared under the CC BY-SA 4.0 license, and you won\'t be listed as an author to protect your privacy.\n\n
+ Thanks for sharing the rights to your data with us. We will put it to good use!\n\n
+ Please note that changing the wording of this notice doesn\'t change what we will do with your data. This isn\'t a contract to be modified, it is just a friendly reminder!\n
+ If you want to send us a message along with your data, write it below the line here.\n
+ ---------------------------------------------\n
+
+
+
diff --git a/app/src/main/res/xml/gesture_data_path.xml b/app/src/main/res/xml/gesture_data_path.xml
new file mode 100644
index 0000000000..a9ec06f5ab
--- /dev/null
+++ b/app/src/main/res/xml/gesture_data_path.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/tools/release.py b/tools/release.py
index e0f5ca2c62..f62f9dd41e 100755
--- a/tools/release.py
+++ b/tools/release.py
@@ -4,6 +4,7 @@
import subprocess
import sys
import zipfile
+import hashlib
from urllib.request import urlretrieve
@@ -112,6 +113,35 @@ def check_changelog():
print("changelog for", version, "does not exist")
+# this is for active data gathering for gesture typing, remove when data gathering phase is done (end of 2026 latest)
+def update_dict_hashes():
+ # assumes the dictionaries repository is in the folder below
+ dicts_dir = "../dictionaries"
+ if not os.path.isdir(dicts_dir):
+ return
+ hashes = []
+ normal_dicts_dir = dicts_dir + "/dictionaries/"
+ for file in os.listdir(normal_dicts_dir):
+ if not file.startswith("main_"):
+ continue
+ with open(normal_dicts_dir + file, "rb") as f:
+ hashes.append(hashlib.sha256(f.read()).hexdigest())
+ experimental_dicts_dir = dicts_dir + "/dictionaries_experimental/"
+ for file in os.listdir(experimental_dicts_dir):
+ if not file.startswith("main_"):
+ continue
+ with open(experimental_dicts_dir + file, "rb") as f:
+ hashes.append(hashlib.sha256(f.read()).hexdigest())
+ hashes_file = "app/src/main/assets/known_dict_hashes.txt"
+ with open(hashes_file) as f:
+ for line in f:
+ if line.strip() in hashes:
+ hashes.remove(line.strip())
+ with open(hashes_file, "a") as f:
+ for line in hashes:
+ f.write(line + "\n")
+
+
# update khipro mapping json, see discussion at the bottom of https://github.com/Helium314/HeliBoard/pull/2134
def update_khipro_mappings():
source = "https://raw.githubusercontent.com/KhiproTeam/Khipro-Mappings/refs/heads/main/output/touchscreen.json"
@@ -128,6 +158,7 @@ def main():
update_dict_list()
update_khipro_mappings()
check_changelog()
+ update_dict_hashes()
if __name__ == "__main__":