Compare commits
39 Commits
main
...
implement-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99902681dc | ||
|
|
eeaef4f5de | ||
|
|
e70a84bea7 | ||
|
|
857e315e6c | ||
|
|
04094985a7 | ||
|
|
d352f9bb11 | ||
|
|
a961defc12 | ||
|
|
f175ffe7ca | ||
|
|
031d9fb75b | ||
|
|
eb23dc8ba1 | ||
|
|
a21114ad03 | ||
|
|
65bf358c75 | ||
|
|
b9d6300fa5 | ||
|
|
be68cb07ac | ||
|
|
9d23925a60 | ||
|
|
25c3229984 | ||
|
|
d7defdce18 | ||
|
|
842d29be04 | ||
|
|
d3ab9effb5 | ||
|
|
c2da93add5 | ||
|
|
de7dc5361a | ||
|
|
90cb84fda0 | ||
|
|
e70c37e022 | ||
|
|
d0f39d18ec | ||
|
|
c24a3c5df3 | ||
|
|
7e482ef9ee | ||
|
|
d604d78109 | ||
|
|
e47e0c537f | ||
|
|
3bf8264d0b | ||
|
|
dded3dddc9 | ||
|
|
252cbcc4f9 | ||
|
|
a22c82baf3 | ||
|
|
9150ec8e36 | ||
|
|
d47900bd93 | ||
|
|
07373ed5b6 | ||
|
|
eb9def1bce | ||
|
|
f43e8d5ea8 | ||
|
|
d2299f0b7d | ||
|
|
7ca1276af5 |
2
app/.gitignore
vendored
2
app/.gitignore
vendored
@@ -1,2 +1,2 @@
|
||||
# Exclude auto-generated icu4c assets
|
||||
src/main/assets/icu4c/
|
||||
.cxx_icu4c
|
||||
|
||||
@@ -29,9 +29,16 @@ plugins {
|
||||
alias(libs.plugins.mikepenz.aboutlibraries)
|
||||
}
|
||||
|
||||
val projectCompileSdk: String by project
|
||||
val projectMinSdk: String by project
|
||||
val projectTargetSdk: String by project
|
||||
val projectVersionCode: String by project
|
||||
val projectVersionName: String by project
|
||||
val projectVersionNameSuffix: String by project
|
||||
|
||||
android {
|
||||
namespace = "dev.patrickgold.florisboard"
|
||||
compileSdk = 33
|
||||
compileSdk = projectCompileSdk.toInt()
|
||||
buildToolsVersion = "33.0.2"
|
||||
ndkVersion = "26.1.10909125"
|
||||
|
||||
@@ -51,10 +58,10 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "dev.patrickgold.florisboard"
|
||||
minSdk = 24
|
||||
targetSdk = 33
|
||||
versionCode = 90
|
||||
versionName = "0.4.0"
|
||||
minSdk = projectMinSdk.toInt()
|
||||
targetSdk = projectTargetSdk.toInt()
|
||||
versionCode = projectVersionCode.toInt()
|
||||
versionName = projectVersionName
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -72,7 +79,8 @@ android {
|
||||
cppFlags("-std=c++20", "-stdlib=libc++")
|
||||
arguments(
|
||||
"-DCMAKE_ANDROID_API=" + minSdk.toString(),
|
||||
"-DICU_ASSET_EXPORT_DIR=" + project.file("src/main/assets/icu4c").absolutePath,
|
||||
"-DICU_ASSET_EXPORT_DIR=" + project.file(".cxx_icu4c/android/assets/icu4c").absolutePath,
|
||||
"-DICU_BUILD_DIR=" + project.file(".cxx_icu4c").absolutePath,
|
||||
"-DBUILD_SHARED_LIBS=false",
|
||||
"-DANDROID_STL=c++_static",
|
||||
)
|
||||
@@ -86,7 +94,7 @@ android {
|
||||
sourceSets {
|
||||
maybeCreate("main").apply {
|
||||
assets {
|
||||
srcDirs("src/main/assets")
|
||||
srcDirs("src/main/assets", ".cxx_icu4c/android/assets")
|
||||
}
|
||||
java {
|
||||
srcDirs("src/main/kotlin")
|
||||
@@ -139,7 +147,7 @@ android {
|
||||
|
||||
create("beta") {
|
||||
applicationIdSuffix = ".beta"
|
||||
versionNameSuffix = "-alpha04"
|
||||
versionNameSuffix = projectVersionNameSuffix
|
||||
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
isMinifyEnabled = true
|
||||
@@ -152,6 +160,8 @@ android {
|
||||
}
|
||||
|
||||
named("release") {
|
||||
versionNameSuffix = projectVersionNameSuffix
|
||||
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
@@ -178,6 +188,16 @@ android {
|
||||
configPath = "app/src/main/config"
|
||||
}
|
||||
|
||||
// This specific block is crucial as it forces the assets packing to be done AFTER the native code has been
|
||||
// compiled. This is important as CMake generates the ICU4C data file which needs to be placed inside the assets
|
||||
// dir, especially though for clean builds on workflow runners and for F-Droid.
|
||||
applicationVariants.all {
|
||||
assembleProvider.configure {
|
||||
dependsOn(javaCompileProvider.get())
|
||||
dependsOn(externalNativeBuildProviders)
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
@@ -198,7 +218,15 @@ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach
|
||||
}
|
||||
}
|
||||
|
||||
val customClean by tasks.registering(Delete::class) {
|
||||
delete(file(".cxx").absolutePath)
|
||||
delete(file(".cxx_icu4c").absolutePath)
|
||||
}
|
||||
tasks.getByName("clean").dependsOn(customClean)
|
||||
|
||||
dependencies {
|
||||
implementation(project(":ime-lib"))
|
||||
implementation(project(":plugin"))
|
||||
implementation(libs.accompanist.flowlayout)
|
||||
implementation(libs.accompanist.systemuicontroller)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
|
||||
<!-- Android 11+ only: Define that FlorisBoard requests to see all apps that
|
||||
ship with an IME or Spell Check service. This is used to guide the user
|
||||
in the Settings Ui why FlorisBoard may not be working.
|
||||
ship with an IME, Spell Check or FlorisBoard Plugin service. This is used
|
||||
to guide the user in the Settings UI why FlorisBoard may not be working and
|
||||
to provide plugin support.
|
||||
-->
|
||||
<queries>
|
||||
<intent>
|
||||
@@ -30,6 +31,9 @@
|
||||
<intent>
|
||||
<action android:name="android.service.textservice.SpellCheckerService"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="org.florisboard.plugin.FlorisPluginService"/>
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
@@ -72,6 +76,16 @@
|
||||
<meta-data android:name="android.view.textservice.scs" android:resource="@xml/spellchecker"/>
|
||||
</service>
|
||||
|
||||
<!-- Internal LatinLanguageProvider plugin service -->
|
||||
<service
|
||||
android:name=".ime.nlp.latin.LatinLanguageProviderService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.florisboard.plugin.FlorisPluginService"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="org.florisboard.plugin.flp" android:resource="@xml/latin_language_provider"/>
|
||||
</service>
|
||||
|
||||
<!-- Main App Activity -->
|
||||
<activity
|
||||
android:name="dev.patrickgold.florisboard.app.FlorisAppActivity"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"$type": "dev.patrickgold.florisboard.ime.theme.Theme",
|
||||
"name": "gboard_day",
|
||||
"label": "Gboard Day",
|
||||
"authors": [ "patrickgold", "itskareem" ],
|
||||
"isNightTheme": false,
|
||||
"attributes": {
|
||||
"window": {
|
||||
"colorPrimary": "#0479ed",
|
||||
"colorPrimaryDark": "#0467c9",
|
||||
"colorAccent": "#FF9800",
|
||||
"navigationBarColor": "@keyboard/background",
|
||||
"navigationBarLight": "true",
|
||||
"semiTransparentColor": "#20000000",
|
||||
"textColor": "#000000"
|
||||
},
|
||||
"keyboard": {
|
||||
"background": "#D1D6DC"
|
||||
},
|
||||
"key": {
|
||||
"background": "#FCFFFF",
|
||||
"backgroundPressed": "#F5F5F5",
|
||||
"foreground": "@window/textColor",
|
||||
"foregroundPressed": "@window/textColor",
|
||||
"showBorder": "true"
|
||||
},
|
||||
"key:enter": {
|
||||
"background": "@window/colorPrimary",
|
||||
"backgroundPressed": "@window/colorPrimaryDark",
|
||||
"foreground": "#FFFFFF",
|
||||
"foregroundPressed": "#FFFFFF"
|
||||
},
|
||||
"key:shift:capslock": {
|
||||
"foreground": "@window/colorAccent",
|
||||
"foregroundPressed": "@window/colorAccent"
|
||||
},
|
||||
"media": {
|
||||
"foreground": "@window/textColor",
|
||||
"foregroundAlt": "#757575"
|
||||
},
|
||||
"oneHanded": {
|
||||
"background": "@keyboard/background",
|
||||
"foreground": "#424242"
|
||||
},
|
||||
"popup": {
|
||||
"background": "#EEEEEE",
|
||||
"backgroundActive": "#BDBDBD",
|
||||
"foreground": "@window/textColor"
|
||||
},
|
||||
"privateMode": {
|
||||
"background": "#A000FF",
|
||||
"foreground": "#FFFFFF"
|
||||
},
|
||||
"smartbar": {
|
||||
"background": "@keyboard/background",
|
||||
"foreground": "@window/textColor",
|
||||
"foregroundAlt": "#8A8A8A"
|
||||
},
|
||||
"smartbarButton": {
|
||||
"background": "@key/background",
|
||||
"foreground": "@key/foreground"
|
||||
},
|
||||
"glideTrail": {"foreground": "#200479ed"}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"$type": "dev.patrickgold.florisboard.ime.theme.Theme",
|
||||
"name": "gboard_night",
|
||||
"label": "Gboard Night",
|
||||
"authors": [ "Netscaping" ],
|
||||
"isNightTheme": true,
|
||||
"attributes": {
|
||||
"window": {
|
||||
"colorPrimary": "#5e97f6",
|
||||
"colorPrimaryDark": "#4285f4",
|
||||
"colorAccent": "#FF9800",
|
||||
"navigationBarColor": "@keyboard/background",
|
||||
"navigationBarLight": "false",
|
||||
"semiTransparentColor": "#20FFFFFF",
|
||||
"textColor": "#FFFFFF"
|
||||
},
|
||||
"keyboard": {
|
||||
"background": "#292e33"
|
||||
},
|
||||
"key": {
|
||||
"background": "#484c4f",
|
||||
"backgroundPressed": "#5e5e60",
|
||||
"foreground": "@window/textColor",
|
||||
"foregroundPressed": "@window/textColor",
|
||||
"showBorder": "true"
|
||||
},
|
||||
"key:enter": {
|
||||
"background": "@window/colorPrimary",
|
||||
"backgroundPressed": "@window/colorPrimaryDark",
|
||||
"foreground": "#FFFFFF",
|
||||
"foregroundPressed": "#FFFFFF"
|
||||
},
|
||||
"key:shift:capslock": {
|
||||
"foreground": "@window/colorAccent",
|
||||
"foregroundPressed": "@window/colorAccent"
|
||||
},
|
||||
"media": {
|
||||
"foreground": "@window/textColor",
|
||||
"foregroundAlt": "#BDBDBD"
|
||||
},
|
||||
"oneHanded": {
|
||||
"background": "#373c41",
|
||||
"foreground": "#9b9da0"
|
||||
},
|
||||
"popup": {
|
||||
"background": "#373c41",
|
||||
"backgroundActive": "#5a5e60",
|
||||
"foreground": "@window/textColor"
|
||||
},
|
||||
"privateMode": {
|
||||
"background": "#A000FF",
|
||||
"foreground": "#FFFFFF"
|
||||
},
|
||||
"smartbar": {
|
||||
"background": "transparent",
|
||||
"foreground": "#d4d5d6",
|
||||
"foregroundAlt": "#73FFFFFF"
|
||||
},
|
||||
"smartbarButton": {
|
||||
"background": "#FFFFFF",
|
||||
"foreground": "#686868"
|
||||
},
|
||||
"glideTrail": {"foreground": "#205e97f6"}
|
||||
}
|
||||
}
|
||||
1
app/src/main/cpp/.clang-format
Symbolic link
1
app/src/main/cpp/.clang-format
Symbolic link
@@ -0,0 +1 @@
|
||||
nlp/.clang-format
|
||||
1
app/src/main/cpp/.clang-tidy
Symbolic link
1
app/src/main/cpp/.clang-tidy
Symbolic link
@@ -0,0 +1 @@
|
||||
nlp/.clang-tidy
|
||||
1
app/src/main/cpp/.editorconfig
Symbolic link
1
app/src/main/cpp/.editorconfig
Symbolic link
@@ -0,0 +1 @@
|
||||
nlp/.editorconfig
|
||||
@@ -13,7 +13,9 @@ add_subdirectory(nlp)
|
||||
add_library(
|
||||
florisboard-native
|
||||
SHARED
|
||||
dev_patrickgold_florisboard_FlorisApplication.cpp
|
||||
FlorisApplication.cpp
|
||||
LatinLanguageProviderService.cpp
|
||||
LatinNlpSession.cpp
|
||||
)
|
||||
|
||||
target_compile_options(florisboard-native PRIVATE -ffunction-sections -fdata-sections -fexceptions)
|
||||
|
||||
@@ -14,22 +14,21 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
#include <jni.h>
|
||||
#include <unicode/udata.h>
|
||||
#include "fl_icuext.hpp"
|
||||
#include "utils/jni_exception.h"
|
||||
#include "utils/jni_utils.h"
|
||||
|
||||
#include "fl_icuext.hpp"
|
||||
#include <jni.h>
|
||||
|
||||
#pragma ide diagnostic ignored "UnusedLocalVariable"
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_dev_patrickgold_florisboard_FlorisApplication_00024Companion_nativeInitICUData(
|
||||
JNIEnv *env, jobject thiz, jobject path)
|
||||
{
|
||||
auto path_str = utils::j2std_string(env, path);
|
||||
auto status = fl::icuext::loadAndSetCommonData(path_str);
|
||||
return status;
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
Java_dev_patrickgold_florisboard_FlorisApplication_00024Companion_nativeInitICUData( //
|
||||
JNIEnv* env,
|
||||
jobject,
|
||||
fl::jni::NativeStr path
|
||||
) {
|
||||
return fl::jni::runInExceptionContainer(env, [&] {
|
||||
auto path_str = fl::jni::j2std_string(env, path);
|
||||
auto status = fl::icuext::loadAndSetCommonData(path_str);
|
||||
return status;
|
||||
});
|
||||
}
|
||||
35
app/src/main/cpp/LatinLanguageProviderService.cpp
Normal file
35
app/src/main/cpp/LatinLanguageProviderService.cpp
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
#include "fl_nlp_core_latin_dictionary.hpp"
|
||||
#include "utils/jni_exception.h"
|
||||
#include "utils/jni_utils.h"
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_dev_patrickgold_florisboard_ime_nlp_latin_LatinLanguageProviderService_00024Companion_nativeInitEmptyDictionary( //
|
||||
JNIEnv* env,
|
||||
jobject,
|
||||
fl::jni::NativeStr j_dict_path
|
||||
) {
|
||||
return fl::jni::runInExceptionContainer(env, [&] {
|
||||
auto dict_path = fl::jni::j2std_string(env, j_dict_path);
|
||||
auto dict = fl::nlp::LatinDictionary(0);
|
||||
dict.file_path = dict_path;
|
||||
dict.persistToDisk();
|
||||
});
|
||||
}
|
||||
112
app/src/main/cpp/LatinNlpSession.cpp
Normal file
112
app/src/main/cpp/LatinNlpSession.cpp
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
#include "fl_nlp_core_latin_nlp_session.hpp"
|
||||
#include "utils/jni_exception.h"
|
||||
#include "utils/jni_utils.h"
|
||||
|
||||
#include <unicode/udata.h>
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
|
||||
extern "C" JNIEXPORT jlong JNICALL
|
||||
Java_dev_patrickgold_florisboard_ime_nlp_latin_LatinNlpSession_00024CXX_nativeInit( //
|
||||
JNIEnv* env,
|
||||
jobject
|
||||
) {
|
||||
return fl::jni::runInExceptionContainer(env, [&] {
|
||||
auto* session = new fl::nlp::LatinNlpSession();
|
||||
return reinterpret_cast<jlong>(session);
|
||||
});
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_dev_patrickgold_florisboard_ime_nlp_latin_LatinNlpSession_00024CXX_nativeDispose( //
|
||||
JNIEnv* env,
|
||||
jobject,
|
||||
jlong native_ptr
|
||||
) {
|
||||
return fl::jni::runInExceptionContainer(env, [&] {
|
||||
auto* session = reinterpret_cast<fl::nlp::LatinNlpSession*>(native_ptr);
|
||||
delete session;
|
||||
});
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_dev_patrickgold_florisboard_ime_nlp_latin_LatinNlpSession_00024CXX_nativeLoadFromConfigFile( //
|
||||
JNIEnv* env,
|
||||
jobject,
|
||||
jlong native_ptr,
|
||||
fl::jni::NativeStr j_config_path
|
||||
) {
|
||||
return fl::jni::runInExceptionContainer(env, [&] {
|
||||
auto* session = reinterpret_cast<fl::nlp::LatinNlpSession*>(native_ptr);
|
||||
auto config_path = fl::jni::j2std_string(env, j_config_path);
|
||||
session->loadConfigFromFile(config_path);
|
||||
});
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT fl::jni::NativeStr JNICALL
|
||||
Java_dev_patrickgold_florisboard_ime_nlp_latin_LatinNlpSession_00024CXX_nativeSpell( //
|
||||
JNIEnv* env,
|
||||
jobject,
|
||||
jlong native_ptr,
|
||||
fl::jni::NativeStr j_word,
|
||||
fl::jni::NativeList j_prev_words,
|
||||
jint flags
|
||||
) {
|
||||
return fl::jni::runInExceptionContainer(env, [&] {
|
||||
auto* session = reinterpret_cast<fl::nlp::LatinNlpSession*>(native_ptr);
|
||||
auto word = fl::jni::j2std_string(env, j_word);
|
||||
auto prev_words = fl::jni::j2std_list<std::string>(env, j_prev_words);
|
||||
auto spelling_result = session->spell(word, prev_words, flags);
|
||||
auto json = nlohmann::json();
|
||||
json["suggestionAttributes"] = spelling_result.suggestion_attributes;
|
||||
json["suggestions"] = spelling_result.suggestions;
|
||||
return fl::jni::std2j_string(env, json.dump());
|
||||
});
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT fl::jni::NativeList JNICALL
|
||||
Java_dev_patrickgold_florisboard_ime_nlp_latin_LatinNlpSession_00024CXX_nativeSuggest( //
|
||||
JNIEnv* env,
|
||||
jobject,
|
||||
jlong native_ptr,
|
||||
fl::jni::NativeStr j_word,
|
||||
fl::jni::NativeList j_prev_words,
|
||||
jint flags
|
||||
) {
|
||||
return fl::jni::runInExceptionContainer(env, [&] {
|
||||
auto* session = reinterpret_cast<fl::nlp::LatinNlpSession*>(native_ptr);
|
||||
auto word = fl::jni::j2std_string(env, j_word);
|
||||
if (word == "null") {
|
||||
int* x = nullptr;
|
||||
*x = 1;
|
||||
}
|
||||
auto prev_words = fl::jni::j2std_list<std::string>(env, j_prev_words);
|
||||
fl::nlp::SuggestionResults suggestion_results;
|
||||
session->suggest(word, prev_words, flags, suggestion_results);
|
||||
std::vector<fl::nlp::SuggestionCandidate> candidates;
|
||||
candidates.reserve(suggestion_results.size());
|
||||
for (auto& candidate_ptr : suggestion_results) {
|
||||
candidates.push_back(std::move(*candidate_ptr));
|
||||
}
|
||||
return fl::jni::std2j_list(env, candidates);
|
||||
});
|
||||
}
|
||||
@@ -4,12 +4,14 @@ add_library(
|
||||
SHARED
|
||||
|
||||
# Headers
|
||||
jni_exception.h
|
||||
jni_utils.h
|
||||
log.h
|
||||
|
||||
# Sources
|
||||
jni_exception.cpp
|
||||
jni_utils.cpp
|
||||
log.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(utils PUBLIC log)
|
||||
target_link_libraries(utils PUBLIC log nlohmann_json::nlohmann_json)
|
||||
|
||||
17
app/src/main/cpp/utils/jni_exception.cpp
Normal file
17
app/src/main/cpp/utils/jni_exception.cpp
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
#include "jni_exception.h"
|
||||
74
app/src/main/cpp/utils/jni_exception.h
Normal file
74
app/src/main/cpp/utils/jni_exception.h
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
#ifndef _FLORISBOARD_JNI_EXCEPTION_H_
|
||||
#define _FLORISBOARD_JNI_EXCEPTION_H_
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <type_traits>
|
||||
#include <typeinfo>
|
||||
|
||||
#define CATCH_EXCEPTION(EXCEPTIONTYPE, EXCEPTIONNAME) \
|
||||
catch (const EXCEPTIONTYPE& e) { \
|
||||
exception_class = env->FindClass("dev/patrickgold/florisboard/native/" EXCEPTIONNAME); \
|
||||
exception_message = e.what(); \
|
||||
}
|
||||
|
||||
namespace fl::jni {
|
||||
|
||||
template<typename F>
|
||||
auto runInExceptionContainer(JNIEnv* env, F&& block) noexcept -> std::invoke_result_t<F> {
|
||||
jclass exception_class = nullptr;
|
||||
const char* exception_message = nullptr;
|
||||
|
||||
try {
|
||||
return block();
|
||||
}
|
||||
// std::logic_error
|
||||
CATCH_EXCEPTION(std::logic_error, "NativeLogicError")
|
||||
CATCH_EXCEPTION(std::invalid_argument, "NativeInvalidArgument")
|
||||
CATCH_EXCEPTION(std::domain_error, "NativeDomainError")
|
||||
CATCH_EXCEPTION(std::length_error, "NativeLengthError")
|
||||
CATCH_EXCEPTION(std::out_of_range, "NativeOutOfRange")
|
||||
// std::runtime_error
|
||||
CATCH_EXCEPTION(std::range_error, "NativeRangeError")
|
||||
CATCH_EXCEPTION(std::overflow_error, "NativeOverflowError")
|
||||
CATCH_EXCEPTION(std::underflow_error, "NativeUnderflowError")
|
||||
CATCH_EXCEPTION(std::runtime_error, "NativeRuntimeError")
|
||||
// std::bad_*
|
||||
CATCH_EXCEPTION(std::bad_array_new_length, "NativeBadArrayNewLength")
|
||||
CATCH_EXCEPTION(std::bad_alloc, "NativeBadAlloc")
|
||||
CATCH_EXCEPTION(std::bad_cast, "NativeBadCast")
|
||||
CATCH_EXCEPTION(std::bad_typeid, "NativeBadTypeid")
|
||||
// std::exception
|
||||
CATCH_EXCEPTION(std::exception, "NativeException")
|
||||
|
||||
if (exception_class == nullptr || exception_message == nullptr) {
|
||||
exception_class = env->FindClass("java/lang/RuntimeException");
|
||||
exception_message = "Unknown error occurred in native code";
|
||||
}
|
||||
|
||||
env->ThrowNew(exception_class, exception_message);
|
||||
return std::invoke_result_t<F>();
|
||||
}
|
||||
|
||||
} // namespace fl::jni
|
||||
|
||||
#endif // _FLORISBOARD_JNI_EXCEPTION_H_
|
||||
@@ -15,20 +15,22 @@
|
||||
*/
|
||||
|
||||
#include "jni_utils.h"
|
||||
|
||||
#include "log.h"
|
||||
|
||||
std::string utils::j2std_string(JNIEnv *env, jobject jStr) {
|
||||
auto cStr = reinterpret_cast<const char *>(env->GetDirectBufferAddress(jStr));
|
||||
auto size = env->GetDirectBufferCapacity(jStr);
|
||||
std::string stdStr(cStr, size);
|
||||
utils::log(ANDROID_LOG_DEBUG, "spell j2s", stdStr);
|
||||
std::string fl::jni::j2std_string(JNIEnv* env, NativeStr jStr) {
|
||||
auto length = env->GetArrayLength(jStr);
|
||||
jbyte* bytes = env->GetByteArrayElements(jStr, nullptr);
|
||||
std::string stdStr(reinterpret_cast<char*>(bytes), length);
|
||||
env->ReleaseByteArrayElements(jStr, bytes, JNI_ABORT);
|
||||
// utils::log(ANDROID_LOG_DEBUG, "fl::jni::j2s", stdStr);
|
||||
return stdStr;
|
||||
}
|
||||
|
||||
jobject utils::std2j_string(JNIEnv *env, const std::string& stdStr) {
|
||||
utils::log(ANDROID_LOG_DEBUG, "spell s2j", stdStr);
|
||||
size_t byteCount = stdStr.length();
|
||||
auto cStr = stdStr.c_str();
|
||||
auto buffer = env->NewDirectByteBuffer((void *) cStr, byteCount);
|
||||
return buffer;
|
||||
fl::jni::NativeStr fl::jni::std2j_string(JNIEnv* env, const std::string& stdStr) {
|
||||
// utils::log(ANDROID_LOG_DEBUG, "fl::jni::s2j", stdStr);
|
||||
auto length = static_cast<jsize>(stdStr.size());
|
||||
NativeStr jStr = env->NewByteArray(length);
|
||||
env->SetByteArrayRegion(jStr, 0, length, reinterpret_cast<const jbyte*>(stdStr.c_str()));
|
||||
return jStr;
|
||||
}
|
||||
|
||||
@@ -17,14 +17,35 @@
|
||||
#ifndef FLORISBOARD_JNI_UTILS_H
|
||||
#define FLORISBOARD_JNI_UTILS_H
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace utils {
|
||||
namespace fl::jni {
|
||||
|
||||
std::string j2std_string(JNIEnv *env, jobject jStr);
|
||||
jobject std2j_string(JNIEnv *env, const std::string& in);
|
||||
using NativeStr = jbyteArray;
|
||||
using NativeList = jbyteArray;
|
||||
|
||||
} // namespace utils
|
||||
std::string j2std_string(JNIEnv* env, NativeStr jStr);
|
||||
NativeStr std2j_string(JNIEnv* env, const std::string& stdStr);
|
||||
|
||||
template<typename T>
|
||||
std::vector<T> j2std_list(JNIEnv* env, NativeList j_list) {
|
||||
auto list_str = j2std_string(env, j_list);
|
||||
auto json = nlohmann::json::parse(list_str);
|
||||
return json.template get<std::vector<T>>();
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
NativeList std2j_list(JNIEnv* env, std::vector<T> list) {
|
||||
auto json = nlohmann::json(list);
|
||||
auto list_str = json.dump();
|
||||
return std2j_string(env, list_str);
|
||||
}
|
||||
|
||||
} // namespace fl::jni
|
||||
|
||||
#endif // FLORISBOARD_JNI_UTILS_H
|
||||
|
||||
@@ -34,7 +34,7 @@ import dev.patrickgold.florisboard.ime.media.emoji.FlorisEmojiCompat
|
||||
import dev.patrickgold.florisboard.ime.nlp.NlpManager
|
||||
import dev.patrickgold.florisboard.ime.text.gestures.GlideTypingManager
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeManager
|
||||
import dev.patrickgold.florisboard.lib.NativeStr
|
||||
import dev.patrickgold.florisboard.native.NativeStr
|
||||
import dev.patrickgold.florisboard.lib.cache.CacheManager
|
||||
import dev.patrickgold.florisboard.lib.crashutility.CrashUtility
|
||||
import dev.patrickgold.florisboard.lib.devtools.Flog
|
||||
@@ -46,7 +46,7 @@ import dev.patrickgold.florisboard.lib.io.AssetManager
|
||||
import dev.patrickgold.florisboard.lib.io.deleteContentsRecursively
|
||||
import dev.patrickgold.florisboard.lib.io.subFile
|
||||
import dev.patrickgold.florisboard.lib.kotlin.tryOrNull
|
||||
import dev.patrickgold.florisboard.lib.toNativeStr
|
||||
import dev.patrickgold.florisboard.native.toNativeStr
|
||||
import dev.patrickgold.jetpref.datastore.JetPref
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
|
||||
@@ -66,13 +66,13 @@ class FlorisSpellCheckerService : SpellCheckerService() {
|
||||
setupSpellingIfNecessary()
|
||||
}
|
||||
|
||||
private fun setupSpellingIfNecessary() {
|
||||
private fun setupSpellingIfNecessary() = runBlocking {
|
||||
val evaluatedSubtype = when (prefs.spelling.languageMode.get()) {
|
||||
SpellingLanguageMode.USE_KEYBOARD_SUBTYPES -> {
|
||||
subtypeManager.activeSubtype
|
||||
}
|
||||
else -> {
|
||||
Subtype.DEFAULT.copy(primaryLocale = FlorisLocale.default())
|
||||
Subtype.FALLBACK.copy(primaryLocale = FlorisLocale.default())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ class FlorisSpellCheckerService : SpellCheckerService() {
|
||||
): Array<SpellingResult> = runBlocking {
|
||||
val retInfos = Array(textInfos.size) { n ->
|
||||
val word = textInfos[n].text ?: ""
|
||||
async { nlpManager.spell(spellingSubtype, word, emptyList(), emptyList(), suggestionsLimit) }
|
||||
async { nlpManager.spell(spellingSubtype, word, emptyList(), suggestionsLimit) }
|
||||
}
|
||||
Array(textInfos.size) { n ->
|
||||
retInfos[n].await().apply {
|
||||
@@ -110,7 +110,7 @@ class FlorisSpellCheckerService : SpellCheckerService() {
|
||||
|
||||
return runBlocking {
|
||||
nlpManager
|
||||
.spell(spellingSubtype, textInfo.text, emptyList(), emptyList(), suggestionsLimit)
|
||||
.spell(spellingSubtype, textInfo.text, emptyList(), suggestionsLimit)
|
||||
.sendToDebugOverlayIfEnabled(textInfo)
|
||||
.suggestionsInfo
|
||||
}
|
||||
|
||||
@@ -502,7 +502,7 @@ class AppPrefs : PreferenceModel("florisboard-app-prefs") {
|
||||
)
|
||||
val activeSubtypeId = long(
|
||||
key = "localization__active_subtype_id",
|
||||
default = Subtype.DEFAULT.id,
|
||||
default = Subtype.FALLBACK.id,
|
||||
)
|
||||
val subtypes = string(
|
||||
key = "localization__subtypes",
|
||||
|
||||
@@ -30,6 +30,7 @@ import dev.patrickgold.florisboard.app.ext.ExtensionExportScreen
|
||||
import dev.patrickgold.florisboard.app.ext.ExtensionImportScreen
|
||||
import dev.patrickgold.florisboard.app.ext.ExtensionImportScreenType
|
||||
import dev.patrickgold.florisboard.app.ext.ExtensionViewScreen
|
||||
import dev.patrickgold.florisboard.app.ext.PluginViewScreen
|
||||
import dev.patrickgold.florisboard.app.settings.HomeScreen
|
||||
import dev.patrickgold.florisboard.app.settings.about.AboutScreen
|
||||
import dev.patrickgold.florisboard.app.settings.about.ProjectLicenseScreen
|
||||
@@ -47,6 +48,7 @@ import dev.patrickgold.florisboard.app.settings.keyboard.KeyboardScreen
|
||||
import dev.patrickgold.florisboard.app.settings.localization.LanguagePackManagerScreen
|
||||
import dev.patrickgold.florisboard.app.settings.localization.LanguagePackManagerScreenAction
|
||||
import dev.patrickgold.florisboard.app.settings.localization.LocalizationScreen
|
||||
import dev.patrickgold.florisboard.app.settings.localization.DictionaryManagerScreen
|
||||
import dev.patrickgold.florisboard.app.settings.localization.SelectLocaleScreen
|
||||
import dev.patrickgold.florisboard.app.settings.localization.SubtypeEditorScreen
|
||||
import dev.patrickgold.florisboard.app.settings.media.MediaScreen
|
||||
@@ -69,6 +71,7 @@ object Routes {
|
||||
|
||||
const val Localization = "settings/localization"
|
||||
const val SelectLocale = "settings/localization/select-locale"
|
||||
const val ManageDictionaries = "settings/localization/manage-dictionaries"
|
||||
const val LanguagePackManager = "settings/localization/language-pack-manage/{action}"
|
||||
fun LanguagePackManager(action: LanguagePackManagerScreenAction) =
|
||||
LanguagePackManager.curlyFormat("action" to action.id)
|
||||
@@ -135,6 +138,11 @@ object Routes {
|
||||
fun View(id: String) = View.curlyFormat("id" to id)
|
||||
}
|
||||
|
||||
object Plugin {
|
||||
const val View = "plugin/view/{id}"
|
||||
fun View(id: String) = View.curlyFormat("id" to id)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavHost(
|
||||
modifier: Modifier,
|
||||
@@ -152,6 +160,7 @@ object Routes {
|
||||
|
||||
composable(Settings.Localization) { LocalizationScreen() }
|
||||
composable(Settings.SelectLocale) { SelectLocaleScreen() }
|
||||
composable(Settings.ManageDictionaries) { DictionaryManagerScreen() }
|
||||
composable(Settings.LanguagePackManager) { navBackStack ->
|
||||
val action = navBackStack.arguments?.getString("action")?.let { actionId ->
|
||||
LanguagePackManagerScreenAction.values().firstOrNull { it.id == actionId }
|
||||
@@ -214,7 +223,7 @@ object Routes {
|
||||
val serialType = navBackStack.arguments?.getString("serial_type")
|
||||
ExtensionEditScreen(
|
||||
id = extensionId.toString(),
|
||||
createSerialType = serialType.takeIf { it != null && it.isNotBlank() },
|
||||
createSerialType = serialType.takeIf { !it.isNullOrBlank() },
|
||||
)
|
||||
}
|
||||
composable(Ext.Export) { navBackStack ->
|
||||
@@ -232,6 +241,11 @@ object Routes {
|
||||
val extensionId = navBackStack.arguments?.getString("id")
|
||||
ExtensionViewScreen(id = extensionId.toString())
|
||||
}
|
||||
|
||||
composable(Plugin.View) { navBackStack ->
|
||||
val pluginId = navBackStack.arguments?.getString("id")
|
||||
PluginViewScreen(id = pluginId.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -47,10 +48,11 @@ import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
import dev.patrickgold.florisboard.cacheManager
|
||||
import dev.patrickgold.florisboard.extensionManager
|
||||
import dev.patrickgold.florisboard.ime.dictionary.DictionaryExtension
|
||||
import dev.patrickgold.florisboard.ime.keyboard.KeyboardExtension
|
||||
import dev.patrickgold.florisboard.ime.nlp.LanguagePackExtension
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtension
|
||||
import dev.patrickgold.florisboard.lib.NATIVE_NULLPTR
|
||||
import dev.patrickgold.florisboard.native.NATIVE_NULLPTR
|
||||
import dev.patrickgold.florisboard.lib.android.showLongToast
|
||||
import dev.patrickgold.florisboard.lib.cache.CacheManager
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisBulletSpacer
|
||||
@@ -61,7 +63,6 @@ import dev.patrickgold.florisboard.lib.compose.FlorisScreen
|
||||
import dev.patrickgold.florisboard.lib.compose.defaultFlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.lib.compose.florisHorizontalScroll
|
||||
import dev.patrickgold.florisboard.lib.compose.stringRes
|
||||
import dev.patrickgold.florisboard.lib.devtools.flogDebug
|
||||
import dev.patrickgold.florisboard.lib.io.FileRegistry
|
||||
import dev.patrickgold.florisboard.lib.kotlin.resultOk
|
||||
|
||||
@@ -85,6 +86,11 @@ enum class ExtensionImportScreenType(
|
||||
titleResId = R.string.ext__import__ext_theme,
|
||||
supportedFiles = listOf(FileRegistry.FlexExtension),
|
||||
),
|
||||
EXT_DICTIONARY(
|
||||
id = "ext-dictionary",
|
||||
titleResId = R.string.ext__import__ext_dictionary,
|
||||
supportedFiles = listOf(FileRegistry.FlexExtension),
|
||||
),
|
||||
EXT_LANGUAGEPACK(
|
||||
id = "ext-languagepack",
|
||||
titleResId = R.string.ext__import__ext_languagepack,
|
||||
@@ -135,7 +141,7 @@ fun ExtensionImportScreen(type: ExtensionImportScreenType, initUuid: String?) =
|
||||
// If uri is null it indicates that the selection activity
|
||||
// was cancelled (mostly by pressing the back button), so
|
||||
// we don't display an error message here.
|
||||
if (uriList.isNullOrEmpty()) return@rememberLauncherForActivityResult
|
||||
if (uriList.isEmpty()) return@rememberLauncherForActivityResult
|
||||
importResult?.getOrNull()?.close()
|
||||
importResult = runCatching { cacheManager.readFromUriIntoCache(uriList) }.map { workspace ->
|
||||
workspace.inputFileInfos.forEach { fileInfo ->
|
||||
@@ -181,6 +187,9 @@ fun ExtensionImportScreen(type: ExtensionImportScreenType, initUuid: String?) =
|
||||
ExtensionImportScreenType.EXT_THEME -> {
|
||||
ext.takeIf { it is ThemeExtension }?.let { extensionManager.import(it) }
|
||||
}
|
||||
ExtensionImportScreenType.EXT_DICTIONARY -> {
|
||||
ext.takeIf { it is DictionaryExtension }?.let { extensionManager.import(it) }
|
||||
}
|
||||
ExtensionImportScreenType.EXT_LANGUAGEPACK -> {
|
||||
ext.takeIf { it is LanguagePackExtension }?.let { extensionManager.import(it) }
|
||||
}
|
||||
@@ -248,6 +257,7 @@ fun ExtensionImportScreen(type: ExtensionImportScreenType, initUuid: String?) =
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun FileInfoView(
|
||||
fileInfo: CacheManager.FileInfo,
|
||||
@@ -264,7 +274,7 @@ private fun FileInfoView(
|
||||
) {
|
||||
val grayColor = LocalContentColor.current.copy(alpha = 0.56f)
|
||||
val ext = fileInfo.ext
|
||||
Row {
|
||||
FlowRow {
|
||||
Text(
|
||||
text = Formatter.formatShortFileSize(LocalContext.current, fileInfo.size),
|
||||
style = MaterialTheme.typography.body2,
|
||||
|
||||
@@ -17,13 +17,11 @@
|
||||
package dev.patrickgold.florisboard.app.ext
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisChip
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun ExtensionKeywordChip(
|
||||
keyword: String,
|
||||
|
||||
@@ -18,7 +18,6 @@ package dev.patrickgold.florisboard.app.ext
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -34,7 +33,6 @@ import dev.patrickgold.florisboard.lib.compose.FlorisChip
|
||||
import dev.patrickgold.florisboard.lib.ext.ExtensionMaintainer
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefAlertDialog
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun ExtensionMaintainerChip(
|
||||
maintainer: ExtensionMaintainer,
|
||||
|
||||
@@ -215,7 +215,7 @@ private fun ViewScreen(ext: Extension) = FlorisScreen {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExtensionMetaRowSimpleText(
|
||||
internal fun ExtensionMetaRowSimpleText(
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
showDividerAbove: Boolean = true,
|
||||
@@ -237,7 +237,7 @@ private fun ExtensionMetaRowSimpleText(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExtensionMetaRowScrollableChips(
|
||||
internal fun ExtensionMetaRowScrollableChips(
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
showDividerAbove: Boolean = true,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ext
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
import dev.patrickgold.florisboard.app.Routes
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisHyperlinkText
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisOutlinedButton
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisScreen
|
||||
import dev.patrickgold.florisboard.lib.compose.stringRes
|
||||
import dev.patrickgold.florisboard.lib.ext.ExtensionMaintainer
|
||||
import dev.patrickgold.florisboard.lib.io.FlorisRef
|
||||
import dev.patrickgold.florisboard.nlpManager
|
||||
import dev.patrickgold.florisboard.plugin.IndexedPlugin
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
@Composable
|
||||
fun PluginViewScreen(id: String) {
|
||||
val context = LocalContext.current
|
||||
val nlpManager by context.nlpManager()
|
||||
|
||||
val plugin = runBlocking {
|
||||
nlpManager.plugins.getOrNull(id)
|
||||
}
|
||||
if (plugin != null) {
|
||||
ViewScreen(plugin)
|
||||
} else {
|
||||
ExtensionNotFoundScreen(id)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ViewScreen(plugin: IndexedPlugin) = FlorisScreen {
|
||||
val packageContext = plugin.packageContext()
|
||||
|
||||
title = plugin.metadata.title.getOrNull(packageContext).toString()
|
||||
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
|
||||
content {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
plugin.metadata.longDescription?.getOrNull(packageContext)?.let { Text(it) }
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
val maintainers = plugin.metadata.maintainers?.getOrNull(packageContext)
|
||||
if (!maintainers.isNullOrEmpty()) {
|
||||
ExtensionMetaRowScrollableChips(
|
||||
label = stringRes(R.string.ext__meta__maintainers),
|
||||
showDividerAbove = false,
|
||||
) {
|
||||
for ((n, maintainer) in maintainers.withIndex()) {
|
||||
if (n > 0) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
val extMaintainer = remember(maintainer) {
|
||||
ExtensionMaintainer.from(maintainer) ?: ExtensionMaintainer(maintainer)
|
||||
}
|
||||
ExtensionMaintainerChip(extMaintainer)
|
||||
}
|
||||
}
|
||||
}
|
||||
ExtensionMetaRowSimpleText(label = stringRes(R.string.ext__meta__id)) {
|
||||
Text(text = plugin.metadata.id)
|
||||
}
|
||||
ExtensionMetaRowSimpleText(label = stringRes(R.string.ext__meta__version)) {
|
||||
Text(text = plugin.metadata.version)
|
||||
}
|
||||
val homepage = plugin.metadata.homepage?.getOrNull(packageContext)
|
||||
if (!homepage.isNullOrBlank()) {
|
||||
ExtensionMetaRowSimpleText(label = stringRes(R.string.ext__meta__homepage)) {
|
||||
FlorisHyperlinkText(
|
||||
text = FlorisRef.fromUrl(homepage).authority,
|
||||
url = homepage,
|
||||
)
|
||||
}
|
||||
}
|
||||
val issueTracker = plugin.metadata.issueTracker?.getOrNull(packageContext)
|
||||
if (!issueTracker.isNullOrBlank()) {
|
||||
ExtensionMetaRowSimpleText(label = stringRes(R.string.ext__meta__issue_tracker)) {
|
||||
FlorisHyperlinkText(
|
||||
text = FlorisRef.fromUrl(issueTracker).authority,
|
||||
url = issueTracker,
|
||||
)
|
||||
}
|
||||
}
|
||||
val privacyPolicy = plugin.metadata.privacyPolicy?.getOrNull(packageContext)
|
||||
if (!privacyPolicy.isNullOrBlank()) {
|
||||
ExtensionMetaRowSimpleText(label = stringRes(R.string.ext__meta__privacy_policy)) {
|
||||
FlorisHyperlinkText(
|
||||
text = FlorisRef.fromUrl(privacyPolicy).authority,
|
||||
url = privacyPolicy,
|
||||
)
|
||||
}
|
||||
}
|
||||
val license = plugin.metadata.license?.getOrNull(packageContext)
|
||||
if (!license.isNullOrBlank()) {
|
||||
ExtensionMetaRowSimpleText(label = stringRes(R.string.ext__meta__license)) {
|
||||
Text(text = license)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
if (plugin.isExternalPlugin()) {
|
||||
FlorisOutlinedButton(
|
||||
onClick = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).also {
|
||||
it.data = Uri.parse("package:${plugin.serviceName.packageName}")
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
icon = painterResource(R.drawable.ic_delete),
|
||||
text = stringRes(R.string.action__uninstall),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colors.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
FlorisOutlinedButton(
|
||||
onClick = {
|
||||
// TODO
|
||||
},
|
||||
icon = painterResource(R.drawable.ic_share),
|
||||
text = stringRes(R.string.action__share),
|
||||
enabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,12 @@ fun AdvancedScreen() = FlorisScreen {
|
||||
}
|
||||
}
|
||||
)
|
||||
ListPreference(
|
||||
prefs.localization.displayLanguageNamesIn,
|
||||
iconId = R.drawable.ic_language,
|
||||
title = stringRes(R.string.settings__localization__display_language_names_in__label),
|
||||
entries = DisplayLanguageNamesIn.listEntries(),
|
||||
)
|
||||
SwitchPreference(
|
||||
prefs.advanced.showAppIcon,
|
||||
iconId = R.drawable.ic_preview,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.settings.localization
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
import dev.patrickgold.florisboard.app.Routes
|
||||
import dev.patrickgold.florisboard.app.ext.ExtensionImportScreenType
|
||||
import dev.patrickgold.florisboard.app.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.extensionManager
|
||||
import dev.patrickgold.florisboard.lib.android.launchUrl
|
||||
import dev.patrickgold.florisboard.lib.android.showLongToast
|
||||
import dev.patrickgold.florisboard.lib.android.showShortToast
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisConfirmDeleteDialog
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisScreen
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisTextButton
|
||||
import dev.patrickgold.florisboard.lib.compose.defaultFlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.lib.compose.rippleClickable
|
||||
import dev.patrickgold.florisboard.lib.compose.stringRes
|
||||
import dev.patrickgold.florisboard.lib.ext.Extension
|
||||
import dev.patrickgold.florisboard.lib.observeAsNonNullState
|
||||
import dev.patrickgold.jetpref.datastore.ui.Preference
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefListItem
|
||||
|
||||
@Composable
|
||||
fun DictionaryManagerScreen() = FlorisScreen {
|
||||
title = "Manage installed dictionaries"
|
||||
|
||||
val prefs by florisPreferenceModel()
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
val extensionManager by context.extensionManager()
|
||||
|
||||
val indexedDictionaryExtensions by extensionManager.dictionaryExtensions.observeAsNonNullState()
|
||||
var dictionaryExtensionToDelete by remember { mutableStateOf<Extension?>(null) }
|
||||
|
||||
content {
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
) {
|
||||
this@content.Preference(
|
||||
onClick = {
|
||||
context.launchUrl("https://github.com/florisboard/nlp/blob/main/data/dicts/v0~draft1/README.md")
|
||||
},
|
||||
iconId = R.drawable.ic_input,
|
||||
title = "Download dictionaries",
|
||||
)
|
||||
}
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
) {
|
||||
this@content.Preference(
|
||||
onClick = { navController.navigate(
|
||||
Routes.Ext.Import(ExtensionImportScreenType.EXT_DICTIONARY, null)
|
||||
) },
|
||||
iconId = R.drawable.ic_input,
|
||||
title = stringRes(R.string.action__import),
|
||||
)
|
||||
}
|
||||
for (ext in indexedDictionaryExtensions) key(ext.meta.id) {
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
title = ext.meta.title,
|
||||
onTitleClick = { navController.navigate(Routes.Ext.View(ext.meta.id)) },
|
||||
subtitle = ext.meta.id,
|
||||
onSubtitleClick = { navController.navigate(Routes.Ext.View(ext.meta.id)) },
|
||||
) {
|
||||
Column(
|
||||
// Allowing horizontal scroll to fit translations in descriptions.
|
||||
Modifier.horizontalScroll(rememberScrollState()).width(intrinsicSize = IntrinsicSize.Max),
|
||||
) {
|
||||
for (dictionary in ext.dictionaries) key(ext.meta.id, dictionary.id) {
|
||||
JetPrefListItem(
|
||||
modifier = Modifier.rippleClickable {
|
||||
//setLanguagePack(ext.meta.id, dictionary.id)
|
||||
},
|
||||
text = dictionary.label,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (extensionManager.canDelete(ext)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 6.dp),
|
||||
) {
|
||||
FlorisTextButton(
|
||||
onClick = {
|
||||
dictionaryExtensionToDelete = ext
|
||||
},
|
||||
icon = painterResource(R.drawable.ic_delete),
|
||||
text = stringRes(R.string.action__delete),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colors.error,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dictionaryExtensionToDelete != null) {
|
||||
FlorisConfirmDeleteDialog(
|
||||
onConfirm = {
|
||||
runCatching {
|
||||
extensionManager.delete(dictionaryExtensionToDelete!!)
|
||||
context.showShortToast("Successfully deleted dictionary")
|
||||
}.onFailure { error ->
|
||||
context.showLongToast(
|
||||
R.string.error__snackbar_message,
|
||||
"error_message" to error.localizedMessage,
|
||||
)
|
||||
}
|
||||
dictionaryExtensionToDelete = null
|
||||
},
|
||||
onDismiss = { dictionaryExtensionToDelete = null },
|
||||
what = dictionaryExtensionToDelete!!.meta.title,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.material.Icon
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.RadioButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -41,6 +42,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
@@ -111,6 +113,13 @@ fun LanguagePackManagerScreen(action: LanguagePackManagerScreenAction?) = Floris
|
||||
var languagePackExtToDelete by remember { mutableStateOf<Extension?>(null) }
|
||||
|
||||
content {
|
||||
FlorisOutlinedBox(modifier = Modifier.defaultFlorisOutlinedBox()) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
text = stringRes(R.string.settings__localization__language_pack_summary),
|
||||
style = MaterialTheme.typography.body2,
|
||||
)
|
||||
}
|
||||
val grayColor = LocalContentColor.current.copy(alpha = 0.56f)
|
||||
if (action == LanguagePackManagerScreenAction.MANAGE) {
|
||||
FlorisOutlinedBox(
|
||||
|
||||
@@ -16,44 +16,51 @@
|
||||
|
||||
package dev.patrickgold.florisboard.app.settings.localization
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.ExtendedFloatingActionButton
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
import dev.patrickgold.florisboard.app.Routes
|
||||
import dev.patrickgold.florisboard.app.settings.advanced.Restore
|
||||
import dev.patrickgold.florisboard.app.settings.theme.ThemeManagerScreenAction
|
||||
import dev.patrickgold.florisboard.cacheManager
|
||||
import dev.patrickgold.florisboard.ime.core.DisplayLanguageNamesIn
|
||||
import dev.patrickgold.florisboard.ime.keyboard.LayoutType
|
||||
import dev.patrickgold.florisboard.ime.nlp.LanguagePackExtension
|
||||
import dev.patrickgold.florisboard.ime.nlp.han.HanShapeBasedLanguageProvider
|
||||
import dev.patrickgold.florisboard.keyboardManager
|
||||
import dev.patrickgold.florisboard.lib.android.readToFile
|
||||
import dev.patrickgold.florisboard.lib.android.showLongToast
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisChip
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisScreen
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisWarningCard
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisTextButton
|
||||
import dev.patrickgold.florisboard.lib.compose.defaultFlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.lib.compose.defaultFlorisOutlinedText
|
||||
import dev.patrickgold.florisboard.lib.compose.florisHorizontalScroll
|
||||
import dev.patrickgold.florisboard.lib.compose.stringRes
|
||||
import dev.patrickgold.florisboard.lib.io.ZipUtils
|
||||
import dev.patrickgold.florisboard.lib.io.parentDir
|
||||
import dev.patrickgold.florisboard.lib.io.subFile
|
||||
import dev.patrickgold.florisboard.lib.observeAsNonNullState
|
||||
import dev.patrickgold.florisboard.nlpManager
|
||||
import dev.patrickgold.florisboard.plugin.FlorisPluginFeature
|
||||
import dev.patrickgold.florisboard.plugin.IndexedPlugin
|
||||
import dev.patrickgold.florisboard.plugin.IndexedPluginState
|
||||
import dev.patrickgold.florisboard.subtypeManager
|
||||
import dev.patrickgold.jetpref.datastore.model.observeAsState
|
||||
import dev.patrickgold.jetpref.datastore.ui.ListPreference
|
||||
import dev.patrickgold.jetpref.datastore.ui.Preference
|
||||
import dev.patrickgold.jetpref.datastore.ui.PreferenceGroup
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefListItem
|
||||
|
||||
private val VerticalGroupMargin = 24.dp
|
||||
|
||||
@Composable
|
||||
fun LocalizationScreen() = FlorisScreen {
|
||||
@@ -64,73 +71,201 @@ fun LocalizationScreen() = FlorisScreen {
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
val keyboardManager by context.keyboardManager()
|
||||
val nlpManager by context.nlpManager()
|
||||
val subtypeManager by context.subtypeManager()
|
||||
val cacheManager by context.cacheManager()
|
||||
|
||||
floatingActionButton {
|
||||
ExtendedFloatingActionButton(
|
||||
icon = { Icon(
|
||||
painter = painterResource(R.drawable.ic_add),
|
||||
contentDescription = stringRes(R.string.settings__localization__subtype_add_title),
|
||||
) },
|
||||
text = { Text(
|
||||
text = stringRes(R.string.settings__localization__subtype_add_title),
|
||||
) },
|
||||
onClick = { navController.navigate(Routes.Settings.SubtypeAdd) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
content {
|
||||
ListPreference(
|
||||
prefs.localization.displayLanguageNamesIn,
|
||||
title = stringRes(R.string.settings__localization__display_language_names_in__label),
|
||||
entries = DisplayLanguageNamesIn.listEntries(),
|
||||
)
|
||||
Preference(
|
||||
// iconId = R.drawable.ic_edit,
|
||||
title = stringRes(R.string.settings__localization__language_pack_title),
|
||||
summary = stringRes(R.string.settings__localization__language_pack_summary),
|
||||
onClick = {
|
||||
navController.navigate(Routes.Settings.LanguagePackManager(LanguagePackManagerScreenAction.MANAGE))
|
||||
},
|
||||
)
|
||||
PreferenceGroup(title = stringRes(R.string.settings__localization__group_subtypes__label)) {
|
||||
val subtypes by subtypeManager.subtypesFlow.collectAsState()
|
||||
if (subtypes.isEmpty()) {
|
||||
FlorisWarningCard(
|
||||
modifier = Modifier.padding(all = 8.dp),
|
||||
text = stringRes(R.string.settings__localization__subtype_no_subtypes_configured_warning),
|
||||
)
|
||||
} else {
|
||||
val currencySets by keyboardManager.resources.currencySets.observeAsNonNullState()
|
||||
val layouts by keyboardManager.resources.layouts.observeAsNonNullState()
|
||||
val displayLanguageNamesIn by prefs.localization.displayLanguageNamesIn.observeAsState()
|
||||
for (subtype in subtypes) {
|
||||
val cMeta = layouts[LayoutType.CHARACTERS]?.get(subtype.layoutMap.characters)
|
||||
val sMeta = layouts[LayoutType.SYMBOLS]?.get(subtype.layoutMap.symbols)
|
||||
val currMeta = currencySets[subtype.currencySet]
|
||||
val summary = stringRes(
|
||||
id = R.string.settings__localization__subtype_summary,
|
||||
"characters_name" to (cMeta?.label ?: "null"),
|
||||
"symbols_name" to (sMeta?.label ?: "null"),
|
||||
"currency_set_name" to (currMeta?.label ?: "null"),
|
||||
PreferenceGroup(title = stringRes(R.string.settings__localization__title)) {
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
title = stringRes(R.string.settings__localization__group_subtypes__label),
|
||||
) {
|
||||
val subtypes by subtypeManager.subtypesFlow.collectAsState()
|
||||
if (subtypes.isEmpty()) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
text = stringRes(R.string.settings__localization__subtype_no_subtypes_configured_warning),
|
||||
fontStyle = FontStyle.Italic,
|
||||
style = MaterialTheme.typography.body2,
|
||||
)
|
||||
Preference(
|
||||
title = when (displayLanguageNamesIn) {
|
||||
DisplayLanguageNamesIn.SYSTEM_LOCALE -> subtype.primaryLocale.displayName()
|
||||
DisplayLanguageNamesIn.NATIVE_LOCALE -> subtype.primaryLocale.displayName(subtype.primaryLocale)
|
||||
} else {
|
||||
val currencySets by keyboardManager.resources.currencySets.observeAsNonNullState()
|
||||
val layouts by keyboardManager.resources.layouts.observeAsNonNullState()
|
||||
val displayLanguageNamesIn by this@content.prefs.localization.displayLanguageNamesIn.observeAsState()
|
||||
for (subtype in subtypes) {
|
||||
val cMeta = layouts[LayoutType.CHARACTERS]?.get(subtype.layoutMap.characters)
|
||||
val sMeta = layouts[LayoutType.SYMBOLS]?.get(subtype.layoutMap.symbols)
|
||||
val currMeta = currencySets[subtype.currencySet]
|
||||
val summary = stringRes(
|
||||
id = R.string.settings__localization__subtype_summary,
|
||||
"characters_name" to (cMeta?.label ?: "null"),
|
||||
"symbols_name" to (sMeta?.label ?: "null"),
|
||||
"currency_set_name" to (currMeta?.label ?: "null"),
|
||||
)
|
||||
JetPrefListItem(
|
||||
modifier = Modifier.clickable {
|
||||
navController.navigate(
|
||||
Routes.Settings.SubtypeEdit(subtype.id)
|
||||
)
|
||||
},
|
||||
text = when (displayLanguageNamesIn) {
|
||||
DisplayLanguageNamesIn.SYSTEM_LOCALE -> subtype.primaryLocale.displayName()
|
||||
DisplayLanguageNamesIn.NATIVE_LOCALE -> subtype.primaryLocale.displayName(subtype.primaryLocale)
|
||||
},
|
||||
secondaryText = summary,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 6.dp),
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
FlorisTextButton(
|
||||
onClick = {
|
||||
navController.navigate(Routes.Settings.SubtypeAdd)
|
||||
},
|
||||
summary = summary,
|
||||
onClick = { navController.navigate(
|
||||
Routes.Settings.SubtypeEdit(subtype.id)
|
||||
) },
|
||||
icon = painterResource(R.drawable.ic_add),
|
||||
text = stringRes(R.string.settings__localization__subtype_add_title),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
title = "Your extensions",
|
||||
) {
|
||||
this@content.Preference(
|
||||
onClick = {
|
||||
navController.navigate(
|
||||
Routes.Settings.LanguagePackManager(LanguagePackManagerScreenAction.MANAGE)
|
||||
)
|
||||
},
|
||||
title = stringRes(R.string.settings__localization__language_pack_title),
|
||||
)
|
||||
this@content.Preference(
|
||||
title = "Manage keyboard layouts (not yet implemented)",
|
||||
enabledIf = { false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//PreferenceGroup(title = stringRes(R.string.settings__localization__group_layouts__label)) {
|
||||
//}
|
||||
PreferenceGroup(
|
||||
modifier = Modifier.padding(top = VerticalGroupMargin),
|
||||
title = "Internal plugins",
|
||||
) {
|
||||
val plugins by nlpManager.plugins.pluginIndexFlow.collectAsState()
|
||||
val (validPlugins, invalidPlugins) = remember(plugins) {
|
||||
plugins.filter { it.isInternalPlugin() }.partition { it.isValid() }
|
||||
}
|
||||
for (plugin in validPlugins) {
|
||||
FlorisPluginBox(plugin)
|
||||
}
|
||||
for (plugin in invalidPlugins) {
|
||||
FlorisInvalidPluginBox(plugin)
|
||||
}
|
||||
}
|
||||
|
||||
PreferenceGroup(
|
||||
modifier = Modifier.padding(top = VerticalGroupMargin),
|
||||
title = "External plugins",
|
||||
) {
|
||||
FlorisOutlinedBox(modifier = Modifier.defaultFlorisOutlinedBox()) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
text = "External plugins are currently unsupported but planned to be supported in the next few alpha releases!",
|
||||
fontStyle = FontStyle.Italic,
|
||||
style = MaterialTheme.typography.body2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(VerticalGroupMargin))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FlorisPluginBox(plugin: IndexedPlugin) {
|
||||
val navController = LocalNavController.current
|
||||
val packageContext = plugin.packageContext()
|
||||
val settingsActivityIntent = plugin.settingsActivityIntent()
|
||||
val configurationRoute = if (plugin.isInternalPlugin()) plugin.configurationRoute()?.takeIf { route ->
|
||||
navController.graph.findNode(route) != null
|
||||
} else null
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
title = plugin.metadata.title.getOrNull(packageContext).toString(),
|
||||
subtitle = plugin.metadata.id,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
text = plugin.metadata.shortDescription?.getOrNull(packageContext).toString(),
|
||||
style = MaterialTheme.typography.body2,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.florisHorizontalScroll(),
|
||||
) {
|
||||
for ((n, feature) in plugin.metadata.features().withIndex()) {
|
||||
if (n > 0) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
FlorisChip(text = florisPluginFeatureToString(feature))
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 6.dp),
|
||||
) {
|
||||
FlorisTextButton(
|
||||
onClick = {
|
||||
navController.navigate(Routes.Plugin.View(plugin.metadata.id))
|
||||
},
|
||||
icon = painterResource(id = R.drawable.ic_info),
|
||||
text = stringRes(R.string.action__info),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colors.onBackground,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
FlorisTextButton(
|
||||
onClick = {
|
||||
if (settingsActivityIntent != null) {
|
||||
packageContext.startActivity(settingsActivityIntent)
|
||||
} else if (configurationRoute != null) {
|
||||
navController.navigate(configurationRoute)
|
||||
}
|
||||
},
|
||||
icon = painterResource(R.drawable.ic_settings),
|
||||
text = stringRes(R.string.action__configure),
|
||||
enabled = configurationRoute != null || settingsActivityIntent != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FlorisInvalidPluginBox(plugin: IndexedPlugin) {
|
||||
val reason = (plugin.state as IndexedPluginState.Error).toString()
|
||||
FlorisOutlinedBox(modifier = Modifier.defaultFlorisOutlinedBox()) {
|
||||
Text(
|
||||
modifier = Modifier.defaultFlorisOutlinedText(),
|
||||
text = "Unrecognised plugin with service name ${plugin.serviceName}\n\nReason: $reason",
|
||||
color = MaterialTheme.colors.error,
|
||||
fontStyle = FontStyle.Italic,
|
||||
style = MaterialTheme.typography.body2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun florisPluginFeatureToString(feature: FlorisPluginFeature): String {
|
||||
return when (feature) {
|
||||
is FlorisPluginFeature.SpellingConfig -> "Spelling"
|
||||
is FlorisPluginFeature.SuggestionConfig -> "Suggestion"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,10 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
@@ -54,6 +56,7 @@ import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
import dev.patrickgold.florisboard.app.Routes
|
||||
import dev.patrickgold.florisboard.app.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.ime.core.ComputedSubtype
|
||||
import dev.patrickgold.florisboard.ime.core.DisplayLanguageNamesIn
|
||||
import dev.patrickgold.florisboard.ime.core.Subtype
|
||||
import dev.patrickgold.florisboard.ime.core.SubtypeJsonConfig
|
||||
@@ -63,18 +66,19 @@ import dev.patrickgold.florisboard.ime.core.SubtypePreset
|
||||
import dev.patrickgold.florisboard.ime.keyboard.LayoutArrangementComponent
|
||||
import dev.patrickgold.florisboard.ime.keyboard.LayoutType
|
||||
import dev.patrickgold.florisboard.ime.keyboard.extCorePopupMapping
|
||||
import dev.patrickgold.florisboard.ime.text.key.KeyCode
|
||||
import dev.patrickgold.florisboard.ime.nlp.han.HanShapeBasedLanguageProvider
|
||||
import dev.patrickgold.florisboard.ime.nlp.latin.LatinLanguageProvider
|
||||
import dev.patrickgold.florisboard.ime.nlp.SubtypeSupportInfo
|
||||
import dev.patrickgold.florisboard.keyboardManager
|
||||
import dev.patrickgold.florisboard.lib.FlorisLocale
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisButtonBar
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisDropdownLikeButton
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisDropdownMenu
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisScreen
|
||||
import dev.patrickgold.florisboard.lib.compose.defaultFlorisOutlinedText
|
||||
import dev.patrickgold.florisboard.lib.compose.stringRes
|
||||
import dev.patrickgold.florisboard.lib.ext.ExtensionComponentName
|
||||
import dev.patrickgold.florisboard.lib.observeAsNonNullState
|
||||
import dev.patrickgold.florisboard.nlpManager
|
||||
import dev.patrickgold.florisboard.subtypeManager
|
||||
import dev.patrickgold.jetpref.datastore.model.observeAsState
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefAlertDialog
|
||||
@@ -86,6 +90,7 @@ private val SelectComponentName = ExtensionComponentName("00", "00")
|
||||
private val SelectNlpProviderId = SelectComponentName.toString()
|
||||
private val SelectNlpProviders = SubtypeNlpProviderMap(
|
||||
spelling = SelectNlpProviderId,
|
||||
suggestion = SelectNlpProviderId,
|
||||
)
|
||||
private val SelectLayoutMap = SubtypeLayoutMap(
|
||||
characters = SelectComponentName,
|
||||
@@ -99,6 +104,7 @@ private val SelectLayoutMap = SubtypeLayoutMap(
|
||||
)
|
||||
private val SelectLocale = FlorisLocale.from("00", "00")
|
||||
private val SelectListKeys = listOf(SelectComponentName)
|
||||
private val SelectNlpProviderKeys = listOf(SelectNlpProviderId)
|
||||
|
||||
private class SubtypeEditorState(init: Subtype?) {
|
||||
companion object {
|
||||
@@ -127,10 +133,11 @@ private class SubtypeEditorState(init: Subtype?) {
|
||||
val id: MutableState<Long> = mutableStateOf(init?.id ?: -1)
|
||||
val primaryLocale: MutableState<FlorisLocale> = mutableStateOf(init?.primaryLocale ?: SelectLocale)
|
||||
val secondaryLocales: MutableState<List<FlorisLocale>> = mutableStateOf(init?.secondaryLocales ?: listOf())
|
||||
val nlpProviders: MutableState<SubtypeNlpProviderMap> = mutableStateOf(init?.nlpProviders ?: Subtype.DEFAULT.nlpProviders)
|
||||
val nlpProviders: MutableState<SubtypeNlpProviderMap> = mutableStateOf(init?.nlpProviders ?: SelectNlpProviders)
|
||||
val composer: MutableState<ExtensionComponentName> = mutableStateOf(init?.composer ?: SelectComponentName)
|
||||
val currencySet: MutableState<ExtensionComponentName> = mutableStateOf(init?.currencySet ?: SelectComponentName)
|
||||
val punctuationRule: MutableState<ExtensionComponentName> = mutableStateOf(init?.punctuationRule ?: Subtype.DEFAULT.punctuationRule)
|
||||
val punctuationRule: MutableState<ExtensionComponentName> =
|
||||
mutableStateOf(init?.punctuationRule ?: Subtype.FALLBACK.punctuationRule)
|
||||
val popupMapping: MutableState<ExtensionComponentName> = mutableStateOf(init?.popupMapping ?: SelectComponentName)
|
||||
val layoutMap: MutableState<SubtypeLayoutMap> = mutableStateOf(init?.layoutMap ?: SelectLayoutMap)
|
||||
|
||||
@@ -171,14 +178,16 @@ private class SubtypeEditorState(init: Subtype?) {
|
||||
|
||||
@Composable
|
||||
fun SubtypeEditorScreen(id: Long?) = FlorisScreen {
|
||||
title = stringRes(if (id == null) {
|
||||
R.string.settings__localization__subtype_add_title
|
||||
} else {
|
||||
R.string.settings__localization__subtype_edit_title
|
||||
})
|
||||
title = stringRes(
|
||||
if (id == null) {
|
||||
R.string.settings__localization__subtype_add_title
|
||||
} else {
|
||||
R.string.settings__localization__subtype_edit_title
|
||||
}
|
||||
)
|
||||
|
||||
val selectValue = stringRes(R.string.settings__localization__subtype_select_placeholder)
|
||||
val selectListValues = remember (selectValue) { listOf(selectValue) }
|
||||
val selectListValues = remember(selectValue) { listOf(selectValue) }
|
||||
|
||||
val prefs by florisPreferenceModel()
|
||||
val navController = LocalNavController.current
|
||||
@@ -186,6 +195,7 @@ fun SubtypeEditorScreen(id: Long?) = FlorisScreen {
|
||||
val configuration = LocalConfiguration.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val keyboardManager by context.keyboardManager()
|
||||
val nlpManager by context.nlpManager()
|
||||
val subtypeManager by context.subtypeManager()
|
||||
|
||||
val displayLanguageNamesIn by prefs.localization.displayLanguageNamesIn.observeAsState()
|
||||
@@ -270,9 +280,10 @@ fun SubtypeEditorScreen(id: Long?) = FlorisScreen {
|
||||
content {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
if (id == null) {
|
||||
Card(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
Text(
|
||||
@@ -306,7 +317,9 @@ fun SubtypeEditorScreen(id: Long?) = FlorisScreen {
|
||||
},
|
||||
text = when (displayLanguageNamesIn) {
|
||||
DisplayLanguageNamesIn.SYSTEM_LOCALE -> suggestedPreset.locale.displayName()
|
||||
DisplayLanguageNamesIn.NATIVE_LOCALE -> suggestedPreset.locale.displayName(suggestedPreset.locale)
|
||||
DisplayLanguageNamesIn.NATIVE_LOCALE -> suggestedPreset.locale.displayName(
|
||||
suggestedPreset.locale
|
||||
)
|
||||
},
|
||||
secondaryText = suggestedPreset.preferred.characters.componentId,
|
||||
)
|
||||
@@ -379,13 +392,18 @@ fun SubtypeEditorScreen(id: Long?) = FlorisScreen {
|
||||
SubtypeProperty(stringRes(R.string.settings__localization__subtype_suggestion_provider)) {
|
||||
// TODO: Put this map somewhere more formal (another KeyboardExtension field?)
|
||||
// optionally use a string resource below
|
||||
val nlpProviderMappings = mapOf(
|
||||
LatinLanguageProvider.ProviderId to "Latin",
|
||||
HanShapeBasedLanguageProvider.ProviderId to "Chinese shape-based"
|
||||
)
|
||||
val plugins by nlpManager.plugins.pluginIndexFlow.collectAsState()
|
||||
val nlpProviderMappings = remember(plugins) {
|
||||
buildMap {
|
||||
for (plugin in plugins) {
|
||||
val packageContext = plugin.packageContext()
|
||||
put(plugin.metadata.id, plugin.metadata.title.getOrNull(packageContext) ?: "??")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val nlpProviderMappingIds = remember(nlpProviderMappings) {
|
||||
SelectListKeys + nlpProviderMappings.keys
|
||||
SelectNlpProviderKeys + nlpProviderMappings.keys
|
||||
}
|
||||
val nlpProviderMappingLabels = remember(nlpProviderMappings) {
|
||||
selectListValues + nlpProviderMappings.values.map { it }
|
||||
@@ -397,13 +415,63 @@ fun SubtypeEditorScreen(id: Long?) = FlorisScreen {
|
||||
expanded = expanded,
|
||||
selectedIndex = selectedIndex,
|
||||
isError = showSelectAsError && selectedIndex == 0,
|
||||
onSelectItem = { nlpProviders = SubtypeNlpProviderMap(
|
||||
suggestion = nlpProviderMappingIds[it] as String,
|
||||
spelling = nlpProviderMappingIds[it] as String
|
||||
) },
|
||||
onSelectItem = {
|
||||
nlpProviders = SubtypeNlpProviderMap(
|
||||
suggestion = nlpProviderMappingIds[it],
|
||||
spelling = nlpProviderMappingIds[it],
|
||||
)
|
||||
},
|
||||
onExpandRequest = { expanded = true },
|
||||
onDismissRequest = { expanded = false },
|
||||
)
|
||||
val subtypeForPluginSupport = ComputedSubtype(
|
||||
id = subtypeEditor.id.value,
|
||||
primaryLocale = subtypeEditor.primaryLocale.value.languageTag(),
|
||||
secondaryLocales = subtypeEditor.secondaryLocales.value.map { it.languageTag() },
|
||||
)
|
||||
val subtypeSupportInfo by produceState<SubtypeSupportInfo?>(
|
||||
initialValue = null,
|
||||
plugins,
|
||||
subtypeForPluginSupport,
|
||||
nlpProviders,
|
||||
) {
|
||||
value = nlpManager.plugins.getOrNull(nlpProviders.suggestion)
|
||||
?.evaluateIsSupported(subtypeForPluginSupport)
|
||||
}
|
||||
val supportInfo = subtypeSupportInfo
|
||||
if (supportInfo == null) {
|
||||
FlorisOutlinedBox {
|
||||
Text(
|
||||
modifier = Modifier.defaultFlorisOutlinedText(),
|
||||
text = "No plugin selected",
|
||||
style = MaterialTheme.typography.body2,
|
||||
)
|
||||
}
|
||||
} else if (supportInfo.isFullySupported()) {
|
||||
FlorisOutlinedBox {
|
||||
Text(
|
||||
modifier = Modifier.defaultFlorisOutlinedText(),
|
||||
text = "Supported",
|
||||
style = MaterialTheme.typography.body2,
|
||||
)
|
||||
}
|
||||
} else if (supportInfo.isPartiallySupported()) {
|
||||
FlorisOutlinedBox {
|
||||
Text(
|
||||
modifier = Modifier.defaultFlorisOutlinedText(),
|
||||
text = "Partially supported\nReason: ${supportInfo.reason}",
|
||||
style = MaterialTheme.typography.body2,
|
||||
)
|
||||
}
|
||||
} else if (supportInfo.isUnsupported()) {
|
||||
FlorisOutlinedBox {
|
||||
Text(
|
||||
modifier = Modifier.defaultFlorisOutlinedText(),
|
||||
text = "Unsupported\nReason: ${supportInfo.reason}",
|
||||
style = MaterialTheme.typography.body2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SubtypeGroupSpacer()
|
||||
@@ -613,7 +681,9 @@ private fun SubtypeLayoutDropdown(
|
||||
|
||||
@Composable
|
||||
private fun SubtypeGroupSpacer() {
|
||||
Spacer(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(32.dp))
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ import dev.patrickgold.florisboard.ime.text.key.KeyCode
|
||||
import dev.patrickgold.florisboard.ime.text.keyboard.TextKeyData
|
||||
import dev.patrickgold.florisboard.ime.theme.FlorisImeUiSpec
|
||||
import dev.patrickgold.florisboard.keyboardManager
|
||||
import dev.patrickgold.florisboard.lib.NATIVE_NULLPTR
|
||||
import dev.patrickgold.florisboard.native.NATIVE_NULLPTR
|
||||
import dev.patrickgold.florisboard.lib.android.showShortToast
|
||||
import dev.patrickgold.florisboard.lib.android.stringRes
|
||||
import dev.patrickgold.florisboard.lib.compose.FlorisChip
|
||||
|
||||
@@ -58,7 +58,6 @@ import dev.patrickgold.florisboard.lib.ext.ExtensionComponentName
|
||||
import dev.patrickgold.florisboard.lib.observeAsNonNullState
|
||||
import dev.patrickgold.florisboard.themeManager
|
||||
import dev.patrickgold.jetpref.datastore.model.observeAsState
|
||||
import dev.patrickgold.jetpref.datastore.ui.ExperimentalJetPrefDatastoreUi
|
||||
import dev.patrickgold.jetpref.datastore.ui.Preference
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefListItem
|
||||
|
||||
@@ -68,7 +67,6 @@ enum class ThemeManagerScreenAction(val id: String) {
|
||||
MANAGE("manage-installed-themes");
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalJetPrefDatastoreUi::class)
|
||||
@Composable
|
||||
fun ThemeManagerScreen(action: ThemeManagerScreenAction?) = FlorisScreen {
|
||||
title = stringRes(when (action) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.clipboard
|
||||
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.ime.clipboard.provider.ClipboardItem
|
||||
import dev.patrickgold.florisboard.ime.clipboard.provider.ItemType
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionProvider
|
||||
import dev.patrickgold.florisboard.lib.util.NetworkUtils
|
||||
|
||||
/**
|
||||
* Default implementation for a clipboard candidate. Should generally not be used by a suggestion provider, except by
|
||||
* the clipboard suggestion provider.
|
||||
*
|
||||
* @see SuggestionCandidate
|
||||
*/
|
||||
class ClipboardSuggestionCandidate(
|
||||
val clipboardItem: ClipboardItem,
|
||||
sourceProvider: SuggestionProvider?,
|
||||
) : SuggestionCandidate(
|
||||
text = clipboardItem.stringRepresentation(),
|
||||
secondaryText = null,
|
||||
confidence = 1.0,
|
||||
isEligibleForAutoCommit = false,
|
||||
isEligibleForUserRemoval = true,
|
||||
iconId = when (clipboardItem.type) {
|
||||
ItemType.TEXT -> when {
|
||||
NetworkUtils.isEmailAddress(clipboardItem.stringRepresentation()) -> R.drawable.ic_email
|
||||
NetworkUtils.isUrl(clipboardItem.stringRepresentation()) -> R.drawable.ic_link
|
||||
NetworkUtils.isPhoneNumber(clipboardItem.stringRepresentation()) -> R.drawable.ic_phone
|
||||
else -> R.drawable.ic_assignment
|
||||
}
|
||||
ItemType.IMAGE -> R.drawable.ic_image
|
||||
ItemType.VIDEO -> R.drawable.ic_videocam
|
||||
},
|
||||
sourceProvider = sourceProvider,
|
||||
)
|
||||
@@ -23,8 +23,6 @@ import dev.patrickgold.florisboard.ime.keyboard.extCoreCurrencySet
|
||||
import dev.patrickgold.florisboard.ime.keyboard.extCoreLayout
|
||||
import dev.patrickgold.florisboard.ime.keyboard.extCorePopupMapping
|
||||
import dev.patrickgold.florisboard.ime.keyboard.extCorePunctuationRule
|
||||
import dev.patrickgold.florisboard.ime.nlp.latin.LatinLanguageProvider
|
||||
import dev.patrickgold.florisboard.ime.nlp.han.HanShapeBasedLanguageProvider
|
||||
import dev.patrickgold.florisboard.lib.FlorisLocale
|
||||
import dev.patrickgold.florisboard.lib.ext.ExtensionComponentName
|
||||
import kotlinx.serialization.SerialName
|
||||
@@ -60,7 +58,7 @@ data class Subtype(
|
||||
/**
|
||||
* Subtype to use when prefs do not contain any valid subtypes.
|
||||
*/
|
||||
val DEFAULT = Subtype(
|
||||
val FALLBACK = Subtype(
|
||||
id = -1,
|
||||
primaryLocale = FlorisLocale.from("en", "US"),
|
||||
secondaryLocales = emptyList(),
|
||||
@@ -94,6 +92,10 @@ data class Subtype(
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun compute(): ComputedSubtype {
|
||||
return ComputedSubtype(id, primaryLocale.languageTag(), secondaryLocales.map { it.languageTag() })
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -208,12 +210,14 @@ data class SubtypeLayoutMap(
|
||||
|
||||
@Serializable
|
||||
data class SubtypeNlpProviderMap(
|
||||
val spelling: String = LatinLanguageProvider.ProviderId,
|
||||
val suggestion: String = LatinLanguageProvider.ProviderId,
|
||||
val spelling: String? = "org.florisboard.plugins.nlp.latin",
|
||||
val spellingDictionary: ExtensionComponentName? = null,
|
||||
val suggestion: String? = "org.florisboard.plugins.nlp.latin",
|
||||
val suggestionDictionary: ExtensionComponentName? = null,
|
||||
) {
|
||||
inline fun forEach(action: (String, String) -> Unit) {
|
||||
action("spelling", spelling)
|
||||
action("suggestion", suggestion)
|
||||
inline fun forEach(action: (String) -> Unit) {
|
||||
if (spelling != null) action(spelling)
|
||||
if (suggestion != null) action(suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,14 @@ package dev.patrickgold.florisboard.ime.core
|
||||
|
||||
import android.content.Context
|
||||
import dev.patrickgold.florisboard.app.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.appContext
|
||||
import dev.patrickgold.florisboard.ime.keyboard.CurrencySet
|
||||
import dev.patrickgold.florisboard.keyboardManager
|
||||
import dev.patrickgold.florisboard.lib.FlorisLocale
|
||||
import dev.patrickgold.florisboard.lib.devtools.flogDebug
|
||||
import dev.patrickgold.florisboard.lib.io.FsDir
|
||||
import dev.patrickgold.florisboard.lib.io.subDir
|
||||
import dev.patrickgold.florisboard.lib.kotlin.curlyFormat
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.decodeFromString
|
||||
@@ -40,6 +44,7 @@ val SubtypeJsonConfig = Json {
|
||||
*/
|
||||
class SubtypeManager(context: Context) {
|
||||
private val prefs by florisPreferenceModel()
|
||||
private val appContext by context.appContext()
|
||||
private val keyboardManager by context.keyboardManager()
|
||||
|
||||
private val _subtypesFlow = MutableStateFlow(listOf<Subtype>())
|
||||
@@ -48,7 +53,7 @@ class SubtypeManager(context: Context) {
|
||||
get() = subtypesFlow.value
|
||||
private set(v) { _subtypesFlow.value = v }
|
||||
|
||||
private val _activeSubtypeFlow = MutableStateFlow(Subtype.DEFAULT)
|
||||
private val _activeSubtypeFlow = MutableStateFlow(Subtype.FALLBACK)
|
||||
val activeSubtypeFlow = _activeSubtypeFlow.asStateFlow()
|
||||
inline var activeSubtype
|
||||
get() = activeSubtypeFlow.value
|
||||
@@ -81,7 +86,7 @@ class SubtypeManager(context: Context) {
|
||||
*/
|
||||
private fun evaluateActiveSubtype(list: List<Subtype>) {
|
||||
val activeSubtypeId = prefs.localization.activeSubtypeId.get()
|
||||
val subtype = list.find { it.id == activeSubtypeId } ?: list.firstOrNull() ?: Subtype.DEFAULT
|
||||
val subtype = list.find { it.id == activeSubtypeId } ?: list.firstOrNull() ?: Subtype.FALLBACK
|
||||
if (subtype.id != activeSubtypeId) {
|
||||
prefs.localization.activeSubtypeId.set(subtype.id)
|
||||
}
|
||||
@@ -189,7 +194,7 @@ class SubtypeManager(context: Context) {
|
||||
val subtypeList = subtypes
|
||||
val cachedActiveSubtype = activeSubtype
|
||||
var triggerNextSubtype = false
|
||||
var newActiveSubtype: Subtype = Subtype.DEFAULT
|
||||
var newActiveSubtype: Subtype = Subtype.FALLBACK
|
||||
for (subtype in subtypeList.asReversed()) {
|
||||
if (triggerNextSubtype) {
|
||||
triggerNextSubtype = false
|
||||
@@ -212,7 +217,7 @@ class SubtypeManager(context: Context) {
|
||||
val subtypeList = subtypes
|
||||
val cachedActiveSubtype = activeSubtype
|
||||
var triggerNextSubtype = false
|
||||
var newActiveSubtype: Subtype = Subtype.DEFAULT
|
||||
var newActiveSubtype: Subtype = Subtype.FALLBACK
|
||||
for (subtype in subtypeList) {
|
||||
if (triggerNextSubtype) {
|
||||
triggerNextSubtype = false
|
||||
@@ -227,4 +232,20 @@ class SubtypeManager(context: Context) {
|
||||
prefs.localization.activeSubtypeId.set(newActiveSubtype.id)
|
||||
activeSubtype = newActiveSubtype
|
||||
}
|
||||
|
||||
fun cacheDirFor(subtype: ComputedSubtype): FsDir {
|
||||
val cacheDir = appContext.cacheDir.subDir(SubtypeDirName.curlyFormat("id" to subtype.id))
|
||||
cacheDir.mkdirs()
|
||||
return cacheDir
|
||||
}
|
||||
|
||||
fun filesDirFor(subtype: ComputedSubtype): FsDir {
|
||||
val filesDir = appContext.filesDir.subDir(SubtypeDirName.curlyFormat("id" to subtype.id))
|
||||
filesDir.mkdirs()
|
||||
return filesDir
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SubtypeDirName = "subtypes/{id}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.dictionary
|
||||
|
||||
import dev.patrickgold.florisboard.lib.FlorisLocale
|
||||
import dev.patrickgold.florisboard.lib.ext.Extension
|
||||
import dev.patrickgold.florisboard.lib.ext.ExtensionComponent
|
||||
import dev.patrickgold.florisboard.lib.ext.ExtensionComponentName
|
||||
import dev.patrickgold.florisboard.lib.ext.ExtensionEditor
|
||||
import dev.patrickgold.florisboard.lib.ext.ExtensionMeta
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class DictionaryComponent(
|
||||
override val id: String,
|
||||
override val label: String,
|
||||
override val authors: List<String>,
|
||||
val locale: FlorisLocale,
|
||||
val dictionaryFile: String? = null,
|
||||
) : ExtensionComponent {
|
||||
fun dictionaryFile() = dictionaryFile ?: "dictionaries/${locale.languageTag()}.fldic"
|
||||
}
|
||||
|
||||
@SerialName(DictionaryExtension.SERIAL_TYPE)
|
||||
@Serializable
|
||||
data class DictionaryExtension(
|
||||
override val meta: ExtensionMeta,
|
||||
override val dependencies: List<String>? = null,
|
||||
val dictionaries: List<DictionaryComponent> = listOf(),
|
||||
) : Extension() {
|
||||
|
||||
companion object {
|
||||
const val SERIAL_TYPE = "ime.extension.dictionary"
|
||||
}
|
||||
|
||||
override fun serialType() = SERIAL_TYPE
|
||||
|
||||
override fun components(): List<ExtensionComponent> {
|
||||
return dictionaries
|
||||
}
|
||||
|
||||
override fun edit(): ExtensionEditor {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun extCoreDictionary(id: String): ExtensionComponentName {
|
||||
val language = id.split("-")[0]
|
||||
return ExtensionComponentName(
|
||||
extensionId = "org.florisboard.dictionaries.${language}",
|
||||
componentId = id,
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import android.content.Context
|
||||
import androidx.room.Room
|
||||
import dev.patrickgold.florisboard.app.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.nlp.WordSuggestionCandidate
|
||||
import dev.patrickgold.florisboard.lib.FlorisLocale
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
@@ -65,24 +64,24 @@ class DictionaryManager private constructor(context: Context) {
|
||||
if (prefs.dictionary.enableFlorisUserDictionary.get()) {
|
||||
florisDao?.query(word, locale)?.let {
|
||||
for (entry in it) {
|
||||
add(WordSuggestionCandidate(entry.word, confidence = entry.freq / 255.0))
|
||||
add(SuggestionCandidate(entry.word, confidence = entry.freq / 255.0))
|
||||
}
|
||||
}
|
||||
florisDao?.queryShortcut(word, locale)?.let {
|
||||
for (entry in it) {
|
||||
add(WordSuggestionCandidate(entry.word, confidence = entry.freq / 255.0))
|
||||
add(SuggestionCandidate(entry.word, confidence = entry.freq / 255.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (prefs.dictionary.enableSystemUserDictionary.get()) {
|
||||
systemDao?.query(word, locale)?.let {
|
||||
for (entry in it) {
|
||||
add(WordSuggestionCandidate(entry.word, confidence = entry.freq / 255.0))
|
||||
add(SuggestionCandidate(entry.word, confidence = entry.freq / 255.0))
|
||||
}
|
||||
}
|
||||
systemDao?.queryShortcut(word, locale)?.let {
|
||||
for (entry in it) {
|
||||
add(WordSuggestionCandidate(entry.word, confidence = entry.freq / 255.0))
|
||||
add(SuggestionCandidate(entry.word, confidence = entry.freq / 255.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
abstract class AbstractEditorInstance(context: Context) {
|
||||
companion object {
|
||||
private const val NumCharsBeforeCursor: Int = 256
|
||||
@@ -242,7 +241,7 @@ abstract class AbstractEditorInstance(context: Context) {
|
||||
} else {
|
||||
EditorRange.Unspecified
|
||||
}
|
||||
val localComposing = if (determineComposingEnabled()) localCurrentWord else EditorRange.Unspecified
|
||||
val localComposing = if (nlpManager.isSuggestionEnabled()) localCurrentWord else EditorRange.Unspecified
|
||||
|
||||
// Build and publish text and content
|
||||
val text = buildString {
|
||||
@@ -276,8 +275,6 @@ abstract class AbstractEditorInstance(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun determineComposingEnabled(): Boolean
|
||||
|
||||
abstract fun determineComposer(composerName: ExtensionComponentName): Composer
|
||||
|
||||
protected open fun shouldDetermineComposingRegion(editorInfo: FlorisEditorInfo): Boolean {
|
||||
|
||||
@@ -111,13 +111,8 @@ class EditorInstance(context: Context) : AbstractEditorInstance(context) {
|
||||
activeState.keyboardMode = keyboardMode
|
||||
activeState.isComposingEnabled = when (keyboardMode) {
|
||||
KeyboardMode.NUMERIC,
|
||||
KeyboardMode.PHONE,
|
||||
KeyboardMode.PHONE2,
|
||||
-> false
|
||||
else -> activeState.keyVariation != KeyVariation.PASSWORD &&
|
||||
prefs.suggestion.enabled.get()// &&
|
||||
//!instance.inputAttributes.flagTextAutoComplete &&
|
||||
//!instance.inputAttributes.flagTextNoSuggestions
|
||||
KeyboardMode.PHONE -> false
|
||||
else -> activeState.keyVariation != KeyVariation.PASSWORD
|
||||
}
|
||||
activeState.isIncognitoMode = when (prefs.advanced.incognitoMode.get()) {
|
||||
IncognitoMode.FORCE_OFF -> false
|
||||
@@ -138,10 +133,6 @@ class EditorInstance(context: Context) : AbstractEditorInstance(context) {
|
||||
}
|
||||
}
|
||||
|
||||
override fun determineComposingEnabled(): Boolean {
|
||||
return nlpManager.isSuggestionOn()
|
||||
}
|
||||
|
||||
override fun determineComposer(composerName: ExtensionComponentName): Composer {
|
||||
return keyboardManager.resources.composers.value?.get(composerName) ?: Appender
|
||||
}
|
||||
@@ -251,7 +242,7 @@ class EditorInstance(context: Context) : AbstractEditorInstance(context) {
|
||||
* @return True on success, false if an error occurred or the input connection is invalid.
|
||||
*/
|
||||
fun commitCompletion(candidate: SuggestionCandidate): Boolean {
|
||||
val text = candidate.text.toString()
|
||||
val text = candidate.text
|
||||
if (text.isEmpty() || activeInfo.isRawInputEditor) return false
|
||||
val content = activeContent
|
||||
return if (content.composing.isValid) {
|
||||
|
||||
@@ -60,7 +60,7 @@ object DefaultComputingEvaluator : ComputingEvaluator {
|
||||
|
||||
override val state = KeyboardState.new()
|
||||
|
||||
override val subtype = Subtype.DEFAULT
|
||||
override val subtype = Subtype.FALLBACK
|
||||
|
||||
override fun context(): Context? = null
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.keyboard
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class KeyProximityChecker(
|
||||
var enabled: Boolean,
|
||||
var mapping: Map<String, List<String>>,
|
||||
)
|
||||
@@ -32,6 +32,7 @@ import dev.patrickgold.florisboard.clipboardManager
|
||||
import dev.patrickgold.florisboard.editorInstance
|
||||
import dev.patrickgold.florisboard.extensionManager
|
||||
import dev.patrickgold.florisboard.ime.ImeUiMode
|
||||
import dev.patrickgold.florisboard.ime.clipboard.ClipboardSuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.core.DisplayLanguageNamesIn
|
||||
import dev.patrickgold.florisboard.ime.core.Subtype
|
||||
import dev.patrickgold.florisboard.ime.core.SubtypePreset
|
||||
@@ -42,7 +43,6 @@ import dev.patrickgold.florisboard.ime.editor.InputAttributes
|
||||
import dev.patrickgold.florisboard.ime.input.InputEventDispatcher
|
||||
import dev.patrickgold.florisboard.ime.input.InputKeyEventReceiver
|
||||
import dev.patrickgold.florisboard.ime.input.InputShiftState
|
||||
import dev.patrickgold.florisboard.ime.nlp.ClipboardSuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.nlp.PunctuationRule
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.onehanded.OneHandedMode
|
||||
@@ -57,7 +57,6 @@ import dev.patrickgold.florisboard.ime.text.keyboard.TextKeyboardCache
|
||||
import dev.patrickgold.florisboard.lib.android.showLongToast
|
||||
import dev.patrickgold.florisboard.lib.android.showShortToast
|
||||
import dev.patrickgold.florisboard.lib.devtools.LogTopic
|
||||
import dev.patrickgold.florisboard.lib.devtools.flogDebug
|
||||
import dev.patrickgold.florisboard.lib.devtools.flogError
|
||||
import dev.patrickgold.florisboard.lib.ext.ExtensionComponentName
|
||||
import dev.patrickgold.florisboard.lib.kotlin.collectIn
|
||||
@@ -147,13 +146,13 @@ class KeyboardManager(context: Context) : InputKeyEventReceiver {
|
||||
reevaluateInputShiftState()
|
||||
updateActiveEvaluators()
|
||||
editorInstance.refreshComposing()
|
||||
resetSuggestions(editorInstance.activeContent)
|
||||
generateSuggestions(editorInstance.activeContent)
|
||||
}
|
||||
clipboardManager.primaryClipFlow.collectLatestIn(scope) {
|
||||
updateActiveEvaluators()
|
||||
}
|
||||
editorInstance.activeContentFlow.collectIn(scope) { content ->
|
||||
resetSuggestions(content)
|
||||
generateSuggestions(content)
|
||||
}
|
||||
prefs.devtools.enabled.observeForever {
|
||||
reevaluateDebugFlags()
|
||||
@@ -213,12 +212,12 @@ class KeyboardManager(context: Context) : InputKeyEventReceiver {
|
||||
}
|
||||
}
|
||||
|
||||
fun resetSuggestions(content: EditorContent) {
|
||||
if (!(activeState.isComposingEnabled || nlpManager.isSuggestionOn())) {
|
||||
private suspend fun generateSuggestions(content: EditorContent) {
|
||||
if (nlpManager.isSuggestionEnabled()) {
|
||||
nlpManager.suggest(subtypeManager.activeSubtype, content)
|
||||
} else {
|
||||
nlpManager.clearSuggestions()
|
||||
return
|
||||
}
|
||||
nlpManager.suggest(subtypeManager.activeSubtype, content)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,7 +277,7 @@ class KeyboardManager(context: Context) : InputKeyEventReceiver {
|
||||
|
||||
fun commitCandidate(candidate: SuggestionCandidate) {
|
||||
scope.launch {
|
||||
candidate.sourceProvider?.notifySuggestionAccepted(subtypeManager.activeSubtype, candidate)
|
||||
candidate.sourceProvider?.notifySuggestionAccepted(subtypeManager.activeSubtype.id, candidate)
|
||||
}
|
||||
when (candidate) {
|
||||
is ClipboardSuggestionCandidate -> editorInstance.commitClipboardItem(candidate.clipboardItem)
|
||||
@@ -397,7 +396,7 @@ class KeyboardManager(context: Context) : InputKeyEventReceiver {
|
||||
candidateForRevert.sourceProvider?.let { sourceProvider ->
|
||||
scope.launch {
|
||||
sourceProvider.notifySuggestionReverted(
|
||||
subtype = subtypeManager.activeSubtype,
|
||||
subtypeId = subtypeManager.activeSubtype.id,
|
||||
candidate = candidateForRevert,
|
||||
)
|
||||
}
|
||||
@@ -985,7 +984,7 @@ class KeyboardManager(context: Context) : InputKeyEventReceiver {
|
||||
keyboard = SmartbarQuickActionsKeyboard,
|
||||
editorInfo = editorInfo,
|
||||
state = state,
|
||||
subtype = Subtype.DEFAULT,
|
||||
subtype = Subtype.FALLBACK,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package dev.patrickgold.florisboard.ime.nlp
|
||||
|
||||
import android.content.Context
|
||||
import android.icu.text.BreakIterator
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.LruCache
|
||||
@@ -29,17 +30,20 @@ import androidx.lifecycle.MutableLiveData
|
||||
import dev.patrickgold.florisboard.app.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.clipboardManager
|
||||
import dev.patrickgold.florisboard.editorInstance
|
||||
import dev.patrickgold.florisboard.extensionManager
|
||||
import dev.patrickgold.florisboard.ime.clipboard.ClipboardSuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.clipboard.provider.ItemType
|
||||
import dev.patrickgold.florisboard.ime.core.ComputedSubtype
|
||||
import dev.patrickgold.florisboard.ime.core.Subtype
|
||||
import dev.patrickgold.florisboard.ime.editor.EditorContent
|
||||
import dev.patrickgold.florisboard.ime.editor.EditorRange
|
||||
import dev.patrickgold.florisboard.ime.nlp.latin.LatinLanguageProvider
|
||||
import dev.patrickgold.florisboard.ime.nlp.han.HanShapeBasedLanguageProvider
|
||||
import dev.patrickgold.florisboard.ime.input.InputShiftState
|
||||
import dev.patrickgold.florisboard.keyboardManager
|
||||
import dev.patrickgold.florisboard.lib.devtools.flogDebug
|
||||
import dev.patrickgold.florisboard.lib.devtools.flogError
|
||||
import dev.patrickgold.florisboard.lib.kotlin.collectLatestIn
|
||||
import dev.patrickgold.florisboard.lib.kotlin.guardedByLock
|
||||
import dev.patrickgold.florisboard.lib.util.NetworkUtils
|
||||
import dev.patrickgold.florisboard.plugin.FlorisPluginIndexer
|
||||
import dev.patrickgold.florisboard.subtypeManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -51,27 +55,21 @@ import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
// TODO: VERY IMPORTANT: This class is the definition of spaghetti code and chaos, clean up or rewrite this class
|
||||
class NlpManager(context: Context) {
|
||||
private val prefs by florisPreferenceModel()
|
||||
private val clipboardManager by context.clipboardManager()
|
||||
private val editorInstance by context.editorInstance()
|
||||
private val extensionManager by context.extensionManager()
|
||||
private val keyboardManager by context.keyboardManager()
|
||||
private val subtypeManager by context.subtypeManager()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private val clipboardSuggestionProvider = ClipboardSuggestionProvider()
|
||||
private val providers = guardedByLock {
|
||||
mapOf(
|
||||
LatinLanguageProvider.ProviderId to ProviderInstanceWrapper(LatinLanguageProvider(context)),
|
||||
HanShapeBasedLanguageProvider.ProviderId to ProviderInstanceWrapper(HanShapeBasedLanguageProvider(context)),
|
||||
)
|
||||
}
|
||||
// lock unnecessary because values constant
|
||||
private val providersForceSuggestionOn = mutableMapOf<String, Boolean>()
|
||||
val plugins = FlorisPluginIndexer(context)
|
||||
|
||||
private val internalSuggestionsGuard = Mutex()
|
||||
private var internalSuggestions by Delegates.observable(SystemClock.uptimeMillis() to listOf<SuggestionCandidate>()) { _, _, _ ->
|
||||
@@ -95,17 +93,39 @@ class NlpManager(context: Context) {
|
||||
private val debugOverlayVersionSource = AtomicInteger(0)
|
||||
|
||||
init {
|
||||
clipboardManager.primaryClipFlow.collectLatestIn(scope) {
|
||||
assembleCandidates()
|
||||
}
|
||||
prefs.suggestion.enabled.observeForever {
|
||||
assembleCandidates()
|
||||
}
|
||||
prefs.suggestion.clipboardContentEnabled.observeForever {
|
||||
assembleCandidates()
|
||||
}
|
||||
subtypeManager.activeSubtypeFlow.collectLatestIn(scope) { subtype ->
|
||||
preload(subtype)
|
||||
scope.launch {
|
||||
plugins.indexBoundServices()
|
||||
flogDebug {
|
||||
buildString {
|
||||
plugins.pluginIndex.withLock { pluginIndex ->
|
||||
appendLine("Indexed Plugins")
|
||||
for (plugin in pluginIndex) {
|
||||
appendLine(plugin.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
plugins.observeServiceChanges()
|
||||
|
||||
clipboardManager.primaryClipFlow.collectLatestIn(scope) {
|
||||
assembleCandidates()
|
||||
}
|
||||
prefs.suggestion.enabled.observeForever {
|
||||
assembleCandidates()
|
||||
}
|
||||
prefs.suggestion.clipboardContentEnabled.observeForever {
|
||||
assembleCandidates()
|
||||
}
|
||||
subtypeManager.activeSubtypeFlow.collectLatestIn(scope) { subtype ->
|
||||
preload(subtype)
|
||||
}
|
||||
extensionManager.dictionaryExtensions.observeForever {
|
||||
runBlocking {
|
||||
for (subtype in subtypeManager.subtypes) {
|
||||
preload(subtype)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,26 +150,9 @@ class NlpManager(context: Context) {
|
||||
?.get(subtype.punctuationRule) ?: PunctuationRule.Fallback
|
||||
}
|
||||
|
||||
private suspend fun getSpellingProvider(subtype: Subtype): SpellingProvider {
|
||||
return providers.withLock { it[subtype.nlpProviders.spelling] }?.provider as? SpellingProvider
|
||||
?: FallbackNlpProvider
|
||||
}
|
||||
|
||||
private suspend fun getSuggestionProvider(subtype: Subtype): SuggestionProvider {
|
||||
return providers.withLock { it[subtype.nlpProviders.suggestion] }?.provider as? SuggestionProvider
|
||||
?: FallbackNlpProvider
|
||||
}
|
||||
|
||||
fun preload(subtype: Subtype) {
|
||||
scope.launch {
|
||||
providers.withLock { providers ->
|
||||
subtype.nlpProviders.forEach { _, providerId ->
|
||||
providers[providerId]?.let { provider ->
|
||||
provider.createIfNecessary()
|
||||
provider.preload(subtype)
|
||||
}
|
||||
}
|
||||
}
|
||||
suspend fun preload(subtype: Subtype) {
|
||||
subtype.nlpProviders.forEach { providerId ->
|
||||
plugins.getOrNull(providerId)?.preload(subtype.compute())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,54 +163,59 @@ class NlpManager(context: Context) {
|
||||
suspend fun spell(
|
||||
subtype: Subtype,
|
||||
word: String,
|
||||
precedingWords: List<String>,
|
||||
followingWords: List<String>,
|
||||
prevWords: List<String>,
|
||||
maxSuggestionCount: Int,
|
||||
): SpellingResult {
|
||||
return getSpellingProvider(subtype).spell(
|
||||
subtype = subtype,
|
||||
return plugins.getOrNull(subtype.nlpProviders.spelling)?.spell(
|
||||
subtypeId = subtype.id,
|
||||
word = word,
|
||||
precedingWords = precedingWords,
|
||||
followingWords = followingWords,
|
||||
maxSuggestionCount = maxSuggestionCount,
|
||||
allowPossiblyOffensive = !prefs.suggestion.blockPossiblyOffensive.get(),
|
||||
isPrivateSession = keyboardManager.activeState.isIncognitoMode,
|
||||
)
|
||||
prevWords = prevWords,
|
||||
flags = activeSuggestionRequestFlags(maxSuggestionCount),
|
||||
) ?: SpellingResult.unspecified()
|
||||
}
|
||||
|
||||
suspend fun determineLocalComposing(
|
||||
textBeforeSelection: CharSequence, breakIterators: BreakIteratorGroup, localLastCommitPosition: Int
|
||||
): EditorRange {
|
||||
return getSuggestionProvider(subtypeManager.activeSubtype).determineLocalComposing(
|
||||
subtypeManager.activeSubtype, textBeforeSelection, breakIterators, localLastCommitPosition
|
||||
)
|
||||
}
|
||||
|
||||
fun providerForcesSuggestionOn(subtype: Subtype): Boolean {
|
||||
// Using a cache because I have no idea how fast the runBlocking is
|
||||
return providersForceSuggestionOn.getOrPut(subtype.nlpProviders.suggestion) {
|
||||
runBlocking {
|
||||
getSuggestionProvider(subtype).forcesSuggestionOn
|
||||
//return nlpProviderRegistry.getSuggestionProvider(subtypeManager.activeSubtype).determineLocalComposing(
|
||||
// subtypeManager.activeSubtype, textBeforeSelection, breakIterators, localLastCommitPosition
|
||||
//)
|
||||
return breakIterators.word(subtypeManager.activeSubtype.primaryLocale) {
|
||||
it.setText(textBeforeSelection.toString())
|
||||
val end = it.last()
|
||||
val isWord = it.ruleStatus != BreakIterator.WORD_NONE
|
||||
if (isWord) {
|
||||
val start = it.previous()
|
||||
EditorRange(start, end)
|
||||
} else {
|
||||
EditorRange.Unspecified
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isSuggestionOn(): Boolean =
|
||||
prefs.suggestion.enabled.get() || providerForcesSuggestionOn(subtypeManager.activeSubtype)
|
||||
private suspend fun providerRequiresSuggestionAlwaysEnabled(subtype: Subtype): Boolean {
|
||||
return plugins.getOrNull(subtype.nlpProviders.suggestion)
|
||||
?.metadata?.suggestionConfig?.requireAlwaysEnabled ?: false
|
||||
}
|
||||
|
||||
suspend fun isSuggestionEnabled(): Boolean {
|
||||
return keyboardManager.activeState.isComposingEnabled &&
|
||||
(prefs.suggestion.enabled.get() || providerRequiresSuggestionAlwaysEnabled(subtypeManager.activeSubtype))
|
||||
}
|
||||
|
||||
fun suggest(subtype: Subtype, content: EditorContent) {
|
||||
val reqTime = SystemClock.uptimeMillis()
|
||||
scope.launch {
|
||||
val suggestions = getSuggestionProvider(subtype).suggest(
|
||||
subtype = subtype,
|
||||
content = content,
|
||||
maxCandidateCount = 8,
|
||||
allowPossiblyOffensive = !prefs.suggestion.blockPossiblyOffensive.get(),
|
||||
isPrivateSession = keyboardManager.activeState.isIncognitoMode,
|
||||
)
|
||||
val candidates = plugins.getOrNull(subtype.nlpProviders.spelling)?.suggest(
|
||||
subtypeId = subtype.id,
|
||||
word = content.composingText,
|
||||
prevWords = content.textBeforeSelection.split(" "), // TODO this split is incorrect
|
||||
flags = activeSuggestionRequestFlags(),
|
||||
) ?: emptyList()
|
||||
flogDebug { "candidates: $candidates" }
|
||||
internalSuggestionsGuard.withLock {
|
||||
if (internalSuggestions.first < reqTime) {
|
||||
internalSuggestions = reqTime to suggestions
|
||||
internalSuggestions = reqTime to candidates
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,7 +240,7 @@ class NlpManager(context: Context) {
|
||||
}
|
||||
|
||||
fun removeSuggestion(subtype: Subtype, candidate: SuggestionCandidate): Boolean {
|
||||
return runBlocking { candidate.sourceProvider?.removeSuggestion(subtype, candidate) == true }.also { result ->
|
||||
return runBlocking { candidate.sourceProvider?.removeSuggestion(subtype.id, candidate) == true }.also { result ->
|
||||
if (result) {
|
||||
scope.launch {
|
||||
// Need to re-trigger the suggestions algorithm
|
||||
@@ -246,24 +254,27 @@ class NlpManager(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun getListOfWords(subtype: Subtype): List<String> {
|
||||
return runBlocking { getSuggestionProvider(subtype).getListOfWords(subtype) }
|
||||
}
|
||||
|
||||
fun getFrequencyForWord(subtype: Subtype, word: String): Double {
|
||||
return runBlocking { getSuggestionProvider(subtype).getFrequencyForWord(subtype, word) }
|
||||
private fun activeSuggestionRequestFlags(maxSuggestionCount: Int? = null): SuggestionRequestFlags {
|
||||
return SuggestionRequestFlags.new(
|
||||
maxSuggestionCount = maxSuggestionCount ?: 8, // TODO make dynamic
|
||||
issStart = InputShiftState.UNSHIFTED, // TODO evaluate correctly
|
||||
issCurrent = InputShiftState.UNSHIFTED, // TODO evaluate correctly
|
||||
maxNgramLevel = 3, // TODO make dynamic
|
||||
allowPossiblyOffensive = !prefs.suggestion.blockPossiblyOffensive.get(),
|
||||
overrideHiddenFlag = false, // TODO make dynamic
|
||||
isPrivateSession = keyboardManager.activeState.isIncognitoMode,
|
||||
)
|
||||
}
|
||||
|
||||
private fun assembleCandidates() {
|
||||
runBlocking {
|
||||
val candidates = when {
|
||||
isSuggestionOn() -> {
|
||||
isSuggestionEnabled() -> {
|
||||
clipboardSuggestionProvider.suggest(
|
||||
subtype = Subtype.DEFAULT,
|
||||
content = editorInstance.activeContent,
|
||||
maxCandidateCount = 8,
|
||||
allowPossiblyOffensive = !prefs.suggestion.blockPossiblyOffensive.get(),
|
||||
isPrivateSession = keyboardManager.activeState.isIncognitoMode,
|
||||
subtypeId = Subtype.FALLBACK.id,
|
||||
word = editorInstance.activeContent.currentWordText,
|
||||
prevWords = listOf(),
|
||||
flags = activeSuggestionRequestFlags(),
|
||||
).ifEmpty {
|
||||
buildList {
|
||||
internalSuggestionsGuard.withLock {
|
||||
@@ -351,41 +362,26 @@ class NlpManager(context: Context) {
|
||||
debugOverlayVersion.postValue(version)
|
||||
}
|
||||
|
||||
private class ProviderInstanceWrapper(val provider: NlpProvider) {
|
||||
private var isInstanceAlive = AtomicBoolean(false)
|
||||
|
||||
suspend fun createIfNecessary() {
|
||||
if (!isInstanceAlive.getAndSet(true)) provider.create()
|
||||
}
|
||||
|
||||
suspend fun preload(subtype: Subtype) {
|
||||
provider.preload(subtype)
|
||||
}
|
||||
|
||||
suspend fun destroyIfNecessary() {
|
||||
if (isInstanceAlive.getAndSet(true)) provider.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
inner class ClipboardSuggestionProvider internal constructor() : SuggestionProvider {
|
||||
private var lastClipboardItemId: Long = -1
|
||||
|
||||
override val providerId = "org.florisboard.nlp.providers.clipboard"
|
||||
|
||||
override suspend fun create() {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
override suspend fun preload(subtype: Subtype) {
|
||||
override suspend fun evaluateIsSupported(subtype: ComputedSubtype): SubtypeSupportInfo {
|
||||
return SubtypeSupportInfo.fullySupported()
|
||||
}
|
||||
|
||||
override suspend fun preload(subtype: ComputedSubtype) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
override suspend fun suggest(
|
||||
subtype: Subtype,
|
||||
content: EditorContent,
|
||||
maxCandidateCount: Int,
|
||||
allowPossiblyOffensive: Boolean,
|
||||
isPrivateSession: Boolean,
|
||||
subtypeId: Long,
|
||||
word: String,
|
||||
prevWords: List<String>,
|
||||
flags: SuggestionRequestFlags,
|
||||
): List<SuggestionCandidate> {
|
||||
// Check if enabled
|
||||
if (!prefs.suggestion.clipboardContentEnabled.get()) return emptyList()
|
||||
@@ -393,7 +389,7 @@ class NlpManager(context: Context) {
|
||||
// Check if already used
|
||||
val currentItem = clipboardManager.primaryClip
|
||||
val lastItemId = lastClipboardItemId
|
||||
if (currentItem == null || currentItem.id == lastItemId || content.text.isNotBlank()) return emptyList()
|
||||
if (currentItem == null || currentItem.id == lastItemId || word.isNotBlank()) return emptyList()
|
||||
|
||||
return buildList {
|
||||
val now = System.currentTimeMillis()
|
||||
@@ -430,17 +426,17 @@ class NlpManager(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun notifySuggestionAccepted(subtype: Subtype, candidate: SuggestionCandidate) {
|
||||
override suspend fun notifySuggestionAccepted(subtypeId: Long, candidate: SuggestionCandidate) {
|
||||
if (candidate is ClipboardSuggestionCandidate) {
|
||||
lastClipboardItemId = candidate.clipboardItem.id
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun notifySuggestionReverted(subtype: Subtype, candidate: SuggestionCandidate) {
|
||||
override suspend fun notifySuggestionReverted(subtypeId: Long, candidate: SuggestionCandidate) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
override suspend fun removeSuggestion(subtype: Subtype, candidate: SuggestionCandidate): Boolean {
|
||||
override suspend fun removeSuggestion(subtypeId: Long, candidate: SuggestionCandidate): Boolean {
|
||||
if (candidate is ClipboardSuggestionCandidate) {
|
||||
lastClipboardItemId = candidate.clipboardItem.id
|
||||
return true
|
||||
@@ -448,14 +444,6 @@ class NlpManager(context: Context) {
|
||||
return false
|
||||
}
|
||||
|
||||
override suspend fun getListOfWords(subtype: Subtype): List<String> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override suspend fun getFrequencyForWord(subtype: Subtype, word: String): Double {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
override suspend fun destroy() {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.nlp
|
||||
|
||||
import android.icu.text.BreakIterator
|
||||
import dev.patrickgold.florisboard.ime.core.Subtype
|
||||
import dev.patrickgold.florisboard.ime.editor.EditorContent
|
||||
import dev.patrickgold.florisboard.ime.editor.EditorRange
|
||||
|
||||
/**
|
||||
* Base interface for any NLP provider implementation. NLP providers maintain their own internal state and only receive
|
||||
* limited events, such as [create], [preload], [destroy] and group specific requests.
|
||||
*
|
||||
* Providers should NEVER do heavy work in the initialization phase of the object, any first-time setup work should be
|
||||
* exclusively done in [create].
|
||||
*
|
||||
* At any point in time there will only be one provider instance per [providerId], even if the instance inherits from
|
||||
* multiple categories at once.
|
||||
*/
|
||||
sealed interface NlpProvider {
|
||||
/**
|
||||
* The unique identifier of this NLP provider for referencing and selection purposes. It should adhere to the
|
||||
* [Java™ package name standards](https://docs.oracle.com/javase/tutorial/java/package/namingpkgs.html), with the
|
||||
* exception that Java keywords are allowed.
|
||||
*/
|
||||
val providerId: String
|
||||
|
||||
/**
|
||||
* Is called exactly once before any [preload] or task specific requests, which allows to make one-time setups, set
|
||||
* up necessary native bindings, threads, etc.
|
||||
*/
|
||||
suspend fun create()
|
||||
|
||||
/**
|
||||
* Is called at least once before a task specific request occurs, to allow for locale-specific preloading of
|
||||
* dictionaries and language models.
|
||||
*
|
||||
* @param subtype Information about the subtype to preload, primarily used for getting the primary and secondary
|
||||
* language for correct dictionary selection.
|
||||
*/
|
||||
suspend fun preload(subtype: Subtype)
|
||||
|
||||
/**
|
||||
* Is called when the provider is no longer needed and should be destroyed. Any native allocations should be freed
|
||||
* up and any asynchronous tasks/threads must be stopped. After this method call finishes, this provider object is
|
||||
* considered dead and will be queued to be cleaned up by the GC in the next round.
|
||||
*/
|
||||
suspend fun destroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for an NLP provider specializing in spell check services.
|
||||
*/
|
||||
interface SpellingProvider : NlpProvider {
|
||||
/**
|
||||
* Spell check given [word] in the primary (and optionally secondary if defined) language of given [subtype], and
|
||||
* return a spelling result. If the given word is spelled correctly, a spelling result with no suggestions should
|
||||
* be returned.
|
||||
*
|
||||
* Spell check requests are considered to be read-only and should at no point be used to train the underlying
|
||||
* language model and/or weights in the dictionary.
|
||||
*
|
||||
* @param subtype Information about the current subtype, primarily used for getting the primary and secondary
|
||||
* language for correct dictionary selection.
|
||||
* @param word The word to spell check, may contain any valid Unicode code point.
|
||||
* @param precedingWords List of preceding words, which allows for a more context-based spellcheck. This list can
|
||||
* also be empty, if no surrounding context can be provided.
|
||||
* @param followingWords List of following words, which allows for a more context-based spellcheck. This list can
|
||||
* also be empty, if no surrounding context can be provided.
|
||||
* @param maxSuggestionCount The maximum number of suggestions this method should return to seamlessly fit into the
|
||||
* UI. Returning more suggestions will result in the overflowing suggestions to be dismissed.
|
||||
* @param allowPossiblyOffensive Flag indicating if possibly offensive words are allowed to be suggested. If false,
|
||||
* the suggestion algorithm must treat possibly offensive input as unknown words and ensure before returning that
|
||||
* no potential offensive words for given language are included. This flag is based on explicit choice of the user
|
||||
* in the Settings and should be respected as best as possible.
|
||||
* @param isPrivateSession Flag indicating if this suggestion call is done within a private session. If true, it
|
||||
* means that this method should only provide suggestions based on already learned data, but MUST NOT use user
|
||||
* input to train the language model. Private sessions are mostly triggered in browser incognito windows and some
|
||||
* messenger apps, however the user may also have this enabled manually.
|
||||
*
|
||||
* @return A spelling result object, which indicates both the validity of this word and if needed suggested
|
||||
* corrections for the misspelled word.
|
||||
*/
|
||||
suspend fun spell(
|
||||
subtype: Subtype,
|
||||
word: String,
|
||||
precedingWords: List<String>,
|
||||
followingWords: List<String>,
|
||||
maxSuggestionCount: Int,
|
||||
allowPossiblyOffensive: Boolean,
|
||||
isPrivateSession: Boolean,
|
||||
): SpellingResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for an NLP provider specializing in next/current-word suggestion and autocorrect services.
|
||||
*/
|
||||
interface SuggestionProvider : NlpProvider {
|
||||
/**
|
||||
* Callback from the editor logic that the editor content has changed and that new suggestions should be generated
|
||||
* for the new user input. There is no guarantee that candidates returned are actually used, as there may be sudden
|
||||
* context changes or clipboard/emoji suggestions overriding the results (if the user has those enabled).
|
||||
*
|
||||
* @param subtype Information about the current subtype, primarily used for getting the primary and secondary
|
||||
* language for correct dictionary selection.
|
||||
* @param content The current content view around the input cursor.
|
||||
* @param maxCandidateCount The maximum number of candidates this method should return to seamlessly fit into the
|
||||
* UI. Returning more candidates will result in the overflowing candidates to be dismissed.
|
||||
* @param allowPossiblyOffensive Flag indicating if possibly offensive words are allowed to be suggested. If false,
|
||||
* the suggestion algorithm must treat possibly offensive input as unknown words and ensure before returning that
|
||||
* no potential offensive words for given language are included. This flag is based on explicit choice of the user
|
||||
* in the Settings and should be respected as best as possible.
|
||||
* @param isPrivateSession Flag indicating if this suggestion call is done within a private session. If true, it
|
||||
* means that this method should only provide suggestions based on already learned data, but MUST NOT use user
|
||||
* input to train the language model. Private sessions are mostly triggered in browser incognito windows and some
|
||||
* messenger apps, however the user may also have this enabled manually.
|
||||
*
|
||||
* @return A list of candidate suggestions for the current editor content state, complying with the max count
|
||||
* restrictions as best as possible. If the provider cannot at all provide any candidates, an empty list should be
|
||||
* returned, in which case the UI automatically adapts and shows alternative actions.
|
||||
*/
|
||||
suspend fun suggest(
|
||||
subtype: Subtype,
|
||||
content: EditorContent,
|
||||
maxCandidateCount: Int,
|
||||
allowPossiblyOffensive: Boolean,
|
||||
isPrivateSession: Boolean,
|
||||
): List<SuggestionCandidate>
|
||||
|
||||
/**
|
||||
* Is called when a suggestion has been accepted, either manually by the user or automatically through auto-commit.
|
||||
* This is purely a notification about an event and can safely be ignored if not needed.
|
||||
*
|
||||
* @param subtype Information about the current subtype, primarily used for getting the primary and secondary
|
||||
* language for correct dictionary selection.
|
||||
* @param candidate The exact suggestion candidate which has been accepted.
|
||||
*/
|
||||
suspend fun notifySuggestionAccepted(subtype: Subtype, candidate: SuggestionCandidate)
|
||||
|
||||
/**
|
||||
* Is called when a previously automatically accepted suggestion has been reverted by the user with backspace. This
|
||||
* is purely a notification about an event and can safely be ignored if not needed.
|
||||
*
|
||||
* @param subtype Information about the current subtype, primarily used for getting the primary and secondary
|
||||
* language for correct dictionary selection.
|
||||
* @param candidate The exact suggestion candidate which has been reverted.
|
||||
*/
|
||||
suspend fun notifySuggestionReverted(subtype: Subtype, candidate: SuggestionCandidate)
|
||||
|
||||
/**
|
||||
* Called if the user requests to prevent a certain suggested word from showing again. It is up to the actual
|
||||
* implementation to adhere to this user request, this removal is not enforced nor monitored by the NLP manager.
|
||||
*
|
||||
* @param subtype Information about the current subtype, primarily used for getting the primary and secondary
|
||||
* language for correct dictionary selection.
|
||||
* @param candidate The exact suggestion candidate which the user does not want to see again.
|
||||
*
|
||||
* @return True if the removal request is supported and is accepted, false otherwise.
|
||||
*/
|
||||
suspend fun removeSuggestion(subtype: Subtype, candidate: SuggestionCandidate): Boolean
|
||||
|
||||
/**
|
||||
* Interop method allowing the glide typing logic to perform its own magic.
|
||||
*
|
||||
* @param subtype Information about the current subtype, primarily used for getting the primary and secondary
|
||||
* language for correct dictionary selection.
|
||||
*
|
||||
* @return The list of word for the given language(s). If the language is not supported, an empty list should be
|
||||
* returned.
|
||||
*/
|
||||
suspend fun getListOfWords(subtype: Subtype): List<String>
|
||||
|
||||
/**
|
||||
* Interop method allowing the glide typing logic to perform its own magic.
|
||||
*
|
||||
* @param subtype Information about the current subtype, primarily used for getting the primary and secondary
|
||||
* language for correct dictionary selection.
|
||||
* @param word The word which frequency is requested.
|
||||
*
|
||||
* @return The frequency of [word] as a double precision floating value between 0.0 and 1.0. If [word] does not
|
||||
* exist, 0.0 should be returned.
|
||||
*/
|
||||
suspend fun getFrequencyForWord(subtype: Subtype, word: String): Double
|
||||
|
||||
/**
|
||||
* When initializing composing text given a new context, the suggestion engine determines the composing range.
|
||||
* The default behavior gets the last word according to the current subtype's primaryLocale.
|
||||
* @param subtype The current subtype used to determine word or character boundary.
|
||||
* @param textBeforeSelection The text whose end we want to compose.
|
||||
* @param breakIterators cache of BreakIterator(s) to determine boundary.
|
||||
*
|
||||
* @return EditorRange indicating composing range.
|
||||
*/
|
||||
suspend fun determineLocalComposing(
|
||||
subtype: Subtype,
|
||||
textBeforeSelection: CharSequence,
|
||||
breakIterators: BreakIteratorGroup,
|
||||
localLastCommitPosition: Int,
|
||||
): EditorRange {
|
||||
return breakIterators.word(subtype.primaryLocale) {
|
||||
it.setText(textBeforeSelection.toString())
|
||||
val end = it.last()
|
||||
val isWord = it.ruleStatus != BreakIterator.WORD_NONE
|
||||
if (isWord) {
|
||||
val start = it.previous()
|
||||
EditorRange(start, end)
|
||||
} else {
|
||||
EditorRange.Unspecified
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val forcesSuggestionOn
|
||||
get() = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback NLP provider which implements all provider variants. Is used in case no other providers can be found.
|
||||
*/
|
||||
object FallbackNlpProvider : SpellingProvider, SuggestionProvider {
|
||||
override val providerId = "org.florisboard.nlp.providers.fallback"
|
||||
|
||||
override suspend fun create() {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
override suspend fun preload(subtype: Subtype) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
override suspend fun spell(
|
||||
subtype: Subtype,
|
||||
word: String,
|
||||
precedingWords: List<String>,
|
||||
followingWords: List<String>,
|
||||
maxSuggestionCount: Int,
|
||||
allowPossiblyOffensive: Boolean,
|
||||
isPrivateSession: Boolean,
|
||||
): SpellingResult {
|
||||
return SpellingResult.unspecified()
|
||||
}
|
||||
|
||||
override suspend fun suggest(
|
||||
subtype: Subtype,
|
||||
content: EditorContent,
|
||||
maxCandidateCount: Int,
|
||||
allowPossiblyOffensive: Boolean,
|
||||
isPrivateSession: Boolean,
|
||||
): List<SuggestionCandidate> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override suspend fun notifySuggestionAccepted(subtype: Subtype, candidate: SuggestionCandidate) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
override suspend fun notifySuggestionReverted(subtype: Subtype, candidate: SuggestionCandidate) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
override suspend fun removeSuggestion(subtype: Subtype, candidate: SuggestionCandidate): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override suspend fun getListOfWords(subtype: Subtype): List<String> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override suspend fun getFrequencyForWord(subtype: Subtype, word: String): Double {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
override suspend fun destroy() {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import dev.patrickgold.florisboard.ime.nlp.SpellingProvider
|
||||
import dev.patrickgold.florisboard.ime.nlp.SpellingResult
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionProvider
|
||||
import dev.patrickgold.florisboard.ime.nlp.WordSuggestionCandidate
|
||||
import dev.patrickgold.florisboard.lib.devtools.flogDebug
|
||||
import dev.patrickgold.florisboard.lib.devtools.flogError
|
||||
import dev.patrickgold.florisboard.subtypeManager
|
||||
@@ -41,7 +40,8 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class HanShapeBasedLanguageProvider(val context: Context) : SpellingProvider, SuggestionProvider {
|
||||
// TODO: rewrite and fix for new plugin based service system
|
||||
/*class HanShapeBasedLanguageProvider(val context: Context) : SpellingProvider, SuggestionProvider {
|
||||
companion object {
|
||||
// Default user ID used for all subtypes, unless otherwise specified.
|
||||
// See `ime/core/Subtype.kt` Line 210 and 211 for the default usage
|
||||
@@ -303,4 +303,4 @@ class HanShapeBasedLanguageProvider(val context: Context) : SpellingProvider, Su
|
||||
|
||||
override val forcesSuggestionOn
|
||||
get() = true
|
||||
}
|
||||
}*/
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.nlp.latin
|
||||
|
||||
import android.content.Context
|
||||
import dev.patrickgold.florisboard.appContext
|
||||
import dev.patrickgold.florisboard.ime.core.Subtype
|
||||
import dev.patrickgold.florisboard.ime.editor.EditorContent
|
||||
import dev.patrickgold.florisboard.ime.nlp.SpellingProvider
|
||||
import dev.patrickgold.florisboard.ime.nlp.SpellingResult
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionProvider
|
||||
import dev.patrickgold.florisboard.ime.nlp.WordSuggestionCandidate
|
||||
import dev.patrickgold.florisboard.lib.android.readText
|
||||
import dev.patrickgold.florisboard.lib.devtools.flogDebug
|
||||
import dev.patrickgold.florisboard.lib.kotlin.guardedByLock
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class LatinLanguageProvider(context: Context) : SpellingProvider, SuggestionProvider {
|
||||
companion object {
|
||||
// Default user ID used for all subtypes, unless otherwise specified.
|
||||
// See `ime/core/Subtype.kt` Line 210 and 211 for the default usage
|
||||
const val ProviderId = "org.florisboard.nlp.providers.latin"
|
||||
}
|
||||
|
||||
private val appContext by context.appContext()
|
||||
|
||||
private val wordData = guardedByLock { mutableMapOf<String, Int>() }
|
||||
private val wordDataSerializer = MapSerializer(String.serializer(), Int.serializer())
|
||||
|
||||
override val providerId = ProviderId
|
||||
|
||||
override suspend fun create() {
|
||||
// Here we initialize our provider, set up all things which are not language dependent.
|
||||
}
|
||||
|
||||
override suspend fun preload(subtype: Subtype) = withContext(Dispatchers.IO) {
|
||||
// Here we have the chance to preload dictionaries and prepare a neural network for a specific language.
|
||||
// Is kept in sync with the active keyboard subtype of the user, however a new preload does not necessary mean
|
||||
// the previous language is not needed anymore (e.g. if the user constantly switches between two subtypes)
|
||||
|
||||
// To read a file from the APK assets the following methods can be used:
|
||||
// appContext.assets.open()
|
||||
// appContext.assets.reader()
|
||||
// appContext.assets.bufferedReader()
|
||||
// appContext.assets.readText()
|
||||
// To copy an APK file/dir to the file system cache (appContext.cacheDir), the following methods are available:
|
||||
// appContext.assets.copy()
|
||||
// appContext.assets.copyRecursively()
|
||||
|
||||
// The subtype we get here contains a lot of data, however we are only interested in subtype.primaryLocale and
|
||||
// subtype.secondaryLocales.
|
||||
|
||||
wordData.withLock { wordData ->
|
||||
if (wordData.isEmpty()) {
|
||||
// Here we use readText() because the test dictionary is a json dictionary
|
||||
val rawData = appContext.assets.readText("ime/dict/data.json")
|
||||
val jsonData = Json.decodeFromString(wordDataSerializer, rawData)
|
||||
wordData.putAll(jsonData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun spell(
|
||||
subtype: Subtype,
|
||||
word: String,
|
||||
precedingWords: List<String>,
|
||||
followingWords: List<String>,
|
||||
maxSuggestionCount: Int,
|
||||
allowPossiblyOffensive: Boolean,
|
||||
isPrivateSession: Boolean,
|
||||
): SpellingResult {
|
||||
return when (word.lowercase()) {
|
||||
// Use typo for typing errors
|
||||
"typo" -> SpellingResult.typo(arrayOf("typo1", "typo2", "typo3"))
|
||||
// Use grammar error if the algorithm can detect this. On Android 11 and lower grammar errors are visually
|
||||
// marked as typos due to a lack of support
|
||||
"gerror" -> SpellingResult.grammarError(arrayOf("grammar1", "grammar2", "grammar3"))
|
||||
// Use valid word for valid input
|
||||
else -> SpellingResult.validWord()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun suggest(
|
||||
subtype: Subtype,
|
||||
content: EditorContent,
|
||||
maxCandidateCount: Int,
|
||||
allowPossiblyOffensive: Boolean,
|
||||
isPrivateSession: Boolean,
|
||||
): List<SuggestionCandidate> {
|
||||
val word = content.composingText.ifBlank { "next" }
|
||||
val suggestions = buildList {
|
||||
for (n in 0 until maxCandidateCount) {
|
||||
add(WordSuggestionCandidate(
|
||||
text = "$word$n",
|
||||
secondaryText = if (n % 2 == 1) "secondary" else null,
|
||||
confidence = 0.5,
|
||||
isEligibleForAutoCommit = false,//n == 0 && word.startsWith("auto"),
|
||||
// We set ourselves as the source provider so we can get notify events for our candidate
|
||||
sourceProvider = this@LatinLanguageProvider,
|
||||
))
|
||||
}
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
override suspend fun notifySuggestionAccepted(subtype: Subtype, candidate: SuggestionCandidate) {
|
||||
// We can use flogDebug, flogInfo, flogWarning and flogError for debug logging, which is a wrapper for Logcat
|
||||
flogDebug { candidate.toString() }
|
||||
}
|
||||
|
||||
override suspend fun notifySuggestionReverted(subtype: Subtype, candidate: SuggestionCandidate) {
|
||||
flogDebug { candidate.toString() }
|
||||
}
|
||||
|
||||
override suspend fun removeSuggestion(subtype: Subtype, candidate: SuggestionCandidate): Boolean {
|
||||
flogDebug { candidate.toString() }
|
||||
return false
|
||||
}
|
||||
|
||||
override suspend fun getListOfWords(subtype: Subtype): List<String> {
|
||||
return wordData.withLock { it.keys.toList() }
|
||||
}
|
||||
|
||||
override suspend fun getFrequencyForWord(subtype: Subtype, word: String): Double {
|
||||
return wordData.withLock { it.getOrDefault(word, 0) / 255.0 }
|
||||
}
|
||||
|
||||
override suspend fun destroy() {
|
||||
// Here we have the chance to de-allocate memory and finish our work. However this might never be called if
|
||||
// the app process is killed (which will most likely always be the case).
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.nlp.latin
|
||||
|
||||
import dev.patrickgold.florisboard.extensionManager
|
||||
import dev.patrickgold.florisboard.ime.core.ComputedSubtype
|
||||
import dev.patrickgold.florisboard.ime.keyboard.KeyProximityChecker
|
||||
import dev.patrickgold.florisboard.ime.nlp.SpellingProvider
|
||||
import dev.patrickgold.florisboard.ime.nlp.SpellingResult
|
||||
import dev.patrickgold.florisboard.ime.nlp.SubtypeSupportInfo
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionProvider
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionRequestFlags
|
||||
import dev.patrickgold.florisboard.lib.FlorisLocale
|
||||
import dev.patrickgold.florisboard.lib.io.subFile
|
||||
import dev.patrickgold.florisboard.lib.io.writeJson
|
||||
import dev.patrickgold.florisboard.lib.kotlin.guardedByLock
|
||||
import dev.patrickgold.florisboard.native.NativeStr
|
||||
import dev.patrickgold.florisboard.native.toNativeStr
|
||||
import dev.patrickgold.florisboard.plugin.FlorisPluginService
|
||||
import dev.patrickgold.florisboard.subtypeManager
|
||||
|
||||
private val DEFAULT_PREDICTION_WEIGHTS = LatinPredictionWeights(
|
||||
lookup = LatinPredictionLookupWeights(
|
||||
maxCostSum = 1.5,
|
||||
costIsEqual = 0.0,
|
||||
costIsEqualIgnoringCase = 0.25,
|
||||
costInsert = 0.5,
|
||||
costInsertStartOfStr = 1.0,
|
||||
costDelete = 0.5,
|
||||
costDeleteStartOfStr = 1.0,
|
||||
costSubstitute = 0.5,
|
||||
costSubstituteInProximity = 0.25,
|
||||
costSubstituteStartOfStr = 1.0,
|
||||
costTranspose = 0.0,
|
||||
),
|
||||
training = LatinPredictionTrainingWeights(
|
||||
usageBonus = 128,
|
||||
usageReductionOthers = 1,
|
||||
),
|
||||
)
|
||||
|
||||
private val DEFAULT_KEY_PROXIMITY_CHECKER = KeyProximityChecker(
|
||||
enabled = false,
|
||||
mapping = mapOf(),
|
||||
)
|
||||
|
||||
private data class LatinNlpSessionWrapper(
|
||||
var subtype: ComputedSubtype,
|
||||
var session: LatinNlpSession,
|
||||
)
|
||||
|
||||
class LatinLanguageProviderService : FlorisPluginService(), SpellingProvider, SuggestionProvider {
|
||||
companion object {
|
||||
const val NlpSessionConfigFileName = "nlp_session_config.json"
|
||||
const val UserDictionaryFileName = "user_dict.fldic"
|
||||
|
||||
external fun nativeInitEmptyDictionary(dictPath: NativeStr)
|
||||
}
|
||||
|
||||
private val extensionManager by extensionManager()
|
||||
private val subtypeManager by subtypeManager()
|
||||
private val cachedSessionWrappers = guardedByLock {
|
||||
mutableListOf<LatinNlpSessionWrapper>()
|
||||
}
|
||||
|
||||
override suspend fun create() {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
override suspend fun preload(subtype: ComputedSubtype) {
|
||||
if (subtype.isFallback()) return
|
||||
cachedSessionWrappers.withLock { sessionWrappers ->
|
||||
var sessionWrapper = sessionWrappers.find { it.subtype.id == subtype.id }
|
||||
if (sessionWrapper == null || sessionWrapper.subtype != subtype) {
|
||||
if (sessionWrapper == null) {
|
||||
sessionWrapper = LatinNlpSessionWrapper(
|
||||
subtype = subtype,
|
||||
session = LatinNlpSession(),
|
||||
)
|
||||
sessionWrappers.add(sessionWrapper)
|
||||
} else {
|
||||
sessionWrapper.subtype = subtype
|
||||
}
|
||||
val cacheDir = subtypeManager.cacheDirFor(subtype)
|
||||
val filesDir = subtypeManager.filesDirFor(subtype)
|
||||
val configFile = cacheDir.subFile(NlpSessionConfigFileName)
|
||||
val userDictFile = filesDir.subFile(UserDictionaryFileName)
|
||||
if (!userDictFile.exists()) {
|
||||
nativeInitEmptyDictionary(userDictFile.absolutePath.toNativeStr())
|
||||
}
|
||||
val config = LatinNlpSessionConfig(
|
||||
primaryLocale = subtype.primaryLocale,
|
||||
secondaryLocales = subtype.secondaryLocales,
|
||||
baseDictionaryPaths = getBaseDictionaryPaths(subtype),
|
||||
userDictionaryPath = userDictFile.absolutePath,
|
||||
predictionWeights = DEFAULT_PREDICTION_WEIGHTS,
|
||||
keyProximityChecker = DEFAULT_KEY_PROXIMITY_CHECKER,
|
||||
)
|
||||
configFile.writeJson(config)
|
||||
sessionWrapper.session.loadFromConfigFile(configFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun evaluateIsSupported(subtype: ComputedSubtype): SubtypeSupportInfo {
|
||||
val baseDictionaries = getBaseDictionaryPaths(subtype)
|
||||
return if (baseDictionaries.isNotEmpty()) {
|
||||
SubtypeSupportInfo.fullySupported()
|
||||
} else {
|
||||
// TODO make string resource and translatable
|
||||
SubtypeSupportInfo.unsupported("No dictionary could be found")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun spell(
|
||||
subtypeId: Long,
|
||||
word: String,
|
||||
prevWords: List<String>,
|
||||
flags: SuggestionRequestFlags,
|
||||
): SpellingResult {
|
||||
return cachedSessionWrappers.withLock { sessionWrappers ->
|
||||
val sessionWrapper = sessionWrappers.find { it.subtype.id == subtypeId }
|
||||
sessionWrapper?.session?.spell(word, prevWords, flags) ?: SpellingResult.unspecified()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun suggest(
|
||||
subtypeId: Long,
|
||||
word: String,
|
||||
prevWords: List<String>,
|
||||
flags: SuggestionRequestFlags,
|
||||
): List<SuggestionCandidate> {
|
||||
return cachedSessionWrappers.withLock { sessionWrappers ->
|
||||
val sessionWrapper = sessionWrappers.find { it.subtype.id == subtypeId }
|
||||
sessionWrapper?.session?.suggest(word, prevWords, flags) ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun notifySuggestionAccepted(subtypeId: Long, candidate: SuggestionCandidate) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun notifySuggestionReverted(subtypeId: Long, candidate: SuggestionCandidate) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun removeSuggestion(subtypeId: Long, candidate: SuggestionCandidate): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun destroy() {
|
||||
//
|
||||
}
|
||||
|
||||
private fun getBaseDictionaryPaths(subtype: ComputedSubtype): List<String> {
|
||||
val primaryLocale = FlorisLocale.fromTag(subtype.primaryLocale)
|
||||
val exactMatchDicts = mutableListOf<String>()
|
||||
val onlyLanguageMatchDicts = mutableListOf<String>()
|
||||
outer@ for (ext in extensionManager.dictionaryExtensions.value!!) {
|
||||
for (dict in ext.dictionaries) {
|
||||
if (dict.locale == primaryLocale) {
|
||||
ext.load(this).onSuccess {
|
||||
exactMatchDicts.add(ext.workingDir!!.subFile(dict.dictionaryFile()).absolutePath)
|
||||
}
|
||||
break@outer
|
||||
} else if (dict.locale.language == primaryLocale.language) {
|
||||
ext.load(this).onSuccess {
|
||||
onlyLanguageMatchDicts.add(ext.workingDir!!.subFile(dict.dictionaryFile()).absolutePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (exactMatchDicts.isEmpty() && onlyLanguageMatchDicts.isNotEmpty()) {
|
||||
exactMatchDicts.add(onlyLanguageMatchDicts.first())
|
||||
}
|
||||
return exactMatchDicts
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.nlp.latin
|
||||
|
||||
import android.view.textservice.SuggestionsInfo
|
||||
import dev.patrickgold.florisboard.ime.keyboard.KeyProximityChecker
|
||||
import dev.patrickgold.florisboard.ime.nlp.SpellingResult
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionRequestFlags
|
||||
import dev.patrickgold.florisboard.lib.io.FsFile
|
||||
import dev.patrickgold.florisboard.lib.kotlin.tryOrNull
|
||||
import dev.patrickgold.florisboard.native.NativeInstanceWrapper
|
||||
import dev.patrickgold.florisboard.native.NativeList
|
||||
import dev.patrickgold.florisboard.native.NativePtr
|
||||
import dev.patrickgold.florisboard.native.NativeStr
|
||||
import dev.patrickgold.florisboard.native.toJavaString
|
||||
import dev.patrickgold.florisboard.native.toNativeList
|
||||
import dev.patrickgold.florisboard.native.toNativeStr
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class LatinNlpSessionConfig(
|
||||
val primaryLocale: String,
|
||||
val secondaryLocales: List<String>,
|
||||
@SerialName("baseDictionaries")
|
||||
val baseDictionaryPaths: List<String>,
|
||||
@SerialName("userDictionary")
|
||||
val userDictionaryPath: String,
|
||||
val predictionWeights: LatinPredictionWeights,
|
||||
val keyProximityChecker: KeyProximityChecker,
|
||||
)
|
||||
|
||||
@JvmInline
|
||||
value class LatinNlpSession(private val _nativePtr: NativePtr = nativeInit()) : NativeInstanceWrapper {
|
||||
suspend fun loadFromConfigFile(configFile: FsFile) {
|
||||
withContext(Dispatchers.IO) {
|
||||
nativeLoadFromConfigFile(_nativePtr, configFile.absolutePath.toNativeStr())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun spell(
|
||||
word: String,
|
||||
prevWords: List<String>,
|
||||
flags: SuggestionRequestFlags,
|
||||
): SpellingResult {
|
||||
return tryOrNull {
|
||||
withContext(Dispatchers.IO) {
|
||||
val nativeSpellingResultStr = nativeSpell(
|
||||
nativePtr = _nativePtr,
|
||||
word = word.toNativeStr(),
|
||||
prevWords = prevWords.toNativeList(),
|
||||
flags = flags.toInt(),
|
||||
).toJavaString()
|
||||
val nativeSpellingResult = Json.decodeFromString<NativeSpellingResult>(nativeSpellingResultStr)
|
||||
SpellingResult(
|
||||
SuggestionsInfo(
|
||||
nativeSpellingResult.suggestionAttributes,
|
||||
nativeSpellingResult.suggestions.toTypedArray(),
|
||||
)
|
||||
)
|
||||
}
|
||||
} ?: SpellingResult.unspecified()
|
||||
}
|
||||
|
||||
suspend fun suggest(
|
||||
word: String,
|
||||
prevWords: List<String>,
|
||||
flags: SuggestionRequestFlags,
|
||||
): List<SuggestionCandidate> {
|
||||
//return tryOrNull {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val nativeCandidatesList = nativeSuggest(
|
||||
nativePtr = _nativePtr,
|
||||
word = word.toNativeStr(),
|
||||
prevWords = prevWords.toNativeList(),
|
||||
flags = flags.toInt(),
|
||||
).toJavaString()
|
||||
Json.decodeFromString(nativeCandidatesList)
|
||||
}
|
||||
//} ?: emptyList()
|
||||
}
|
||||
|
||||
override fun nativePtr(): NativePtr {
|
||||
return _nativePtr
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
nativeDispose(_nativePtr)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class NativeSpellingResult(
|
||||
val suggestionAttributes: Int,
|
||||
val suggestions: List<String>,
|
||||
)
|
||||
|
||||
companion object CXX {
|
||||
external fun nativeInit(): NativePtr
|
||||
|
||||
external fun nativeDispose(nativePtr: NativePtr)
|
||||
|
||||
external fun nativeLoadFromConfigFile(nativePtr: NativePtr, configPath: NativeStr)
|
||||
|
||||
external fun nativeSpell(
|
||||
nativePtr: NativePtr,
|
||||
word: NativeStr,
|
||||
prevWords: NativeList,
|
||||
flags: Int,
|
||||
): NativeStr
|
||||
|
||||
external fun nativeSuggest(
|
||||
nativePtr: NativePtr,
|
||||
word: NativeStr,
|
||||
prevWords: NativeList,
|
||||
flags: Int,
|
||||
): NativeList
|
||||
|
||||
//external fun nativeTrain(sentence: List<NativeStr>, maxPrevWords: Int)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.nlp.latin
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LatinPredictionLookupWeights(
|
||||
val maxCostSum: Double,
|
||||
val costIsEqual: Double,
|
||||
val costIsEqualIgnoringCase: Double,
|
||||
val costInsert: Double,
|
||||
val costInsertStartOfStr: Double,
|
||||
val costDelete: Double,
|
||||
val costDeleteStartOfStr: Double,
|
||||
val costSubstitute: Double,
|
||||
val costSubstituteInProximity: Double,
|
||||
val costSubstituteStartOfStr: Double,
|
||||
val costTranspose: Double,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LatinPredictionTrainingWeights(
|
||||
val usageBonus: Int,
|
||||
val usageReductionOthers: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LatinPredictionWeights(
|
||||
val lookup: LatinPredictionLookupWeights,
|
||||
val training: LatinPredictionTrainingWeights,
|
||||
)
|
||||
@@ -51,7 +51,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.app.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.ime.nlp.ClipboardSuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.clipboard.ClipboardSuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.theme.FlorisImeTheme
|
||||
import dev.patrickgold.florisboard.ime.theme.FlorisImeUi
|
||||
@@ -251,7 +251,7 @@ private fun CandidateItem(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (candidate.secondaryText != null) {
|
||||
if (!candidate.secondaryText.isNullOrEmpty()) {
|
||||
Text(
|
||||
text = candidate.secondaryText!!.toString(),
|
||||
color = style.foreground.solidColor(context),
|
||||
|
||||
@@ -2,7 +2,7 @@ package dev.patrickgold.florisboard.ime.text.gestures
|
||||
|
||||
import android.content.Context
|
||||
import dev.patrickgold.florisboard.app.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.ime.nlp.WordSuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.text.keyboard.TextKey
|
||||
import dev.patrickgold.florisboard.keyboardManager
|
||||
import dev.patrickgold.florisboard.nlpManager
|
||||
@@ -86,7 +86,7 @@ class GlideTypingManager(context: Context) : GlideTypingGesture.Listener {
|
||||
1.coerceAtMost(min(commit.compareTo(false), suggestions.size)),
|
||||
maxSuggestionsToShow.coerceAtMost(suggestions.size)
|
||||
).map { keyboardManager.fixCase(it) }.forEach {
|
||||
add(WordSuggestionCandidate(it, confidence = 1.0))
|
||||
add(SuggestionCandidate(it, confidence = 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ class StatisticalGlideTypingClassifier(context: Context) : GlideTypingClassifier
|
||||
return
|
||||
}
|
||||
|
||||
this.words = nlpManager.getListOfWords(subtype)
|
||||
this.words = emptyList() //nlpManager.getListOfWords(subtype)
|
||||
|
||||
this.wordDataSubtype = subtype
|
||||
if (wordDataSubtype == layoutSubtype) {
|
||||
@@ -203,7 +203,7 @@ class StatisticalGlideTypingClassifier(context: Context) : GlideTypingClassifier
|
||||
val locationDistance = calcLocationDistance(wordGesture, userGesture)
|
||||
val shapeProbability = calcGaussianProbability(shapeDistance, 0.0f, SHAPE_STD)
|
||||
val locationProbability = calcGaussianProbability(locationDistance, 0.0f, LOCATION_STD * radius)
|
||||
val frequency = 255f * nlpManager.getFrequencyForWord(currentSubtype!!, word).toFloat()
|
||||
val frequency = 255f * 0.0f //nlpManager.getFrequencyForWord(currentSubtype!!, word).toFloat()
|
||||
val confidence = 1.0f / (shapeProbability * locationProbability * frequency)
|
||||
|
||||
var candidateDistanceSortedIndex = 0
|
||||
|
||||
@@ -27,7 +27,7 @@ import dev.patrickgold.florisboard.app.ext.EditorAction
|
||||
import dev.patrickgold.florisboard.app.settings.advanced.Backup
|
||||
import dev.patrickgold.florisboard.appContext
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionEditor
|
||||
import dev.patrickgold.florisboard.lib.NATIVE_NULLPTR
|
||||
import dev.patrickgold.florisboard.native.NATIVE_NULLPTR
|
||||
import dev.patrickgold.florisboard.lib.android.query
|
||||
import dev.patrickgold.florisboard.lib.android.readToFile
|
||||
import dev.patrickgold.florisboard.lib.ext.Extension
|
||||
|
||||
@@ -301,3 +301,8 @@ fun Modifier.defaultFlorisOutlinedBox(): Modifier {
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
}
|
||||
|
||||
fun Modifier.defaultFlorisOutlinedText(): Modifier {
|
||||
return this
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
}
|
||||
|
||||
@@ -60,10 +60,10 @@ data class ExtensionMaintainer(
|
||||
|
||||
override fun toString() = buildString {
|
||||
append(name)
|
||||
if (email != null && email.isNotBlank()) {
|
||||
if (!email.isNullOrBlank()) {
|
||||
append(" <$email>")
|
||||
}
|
||||
if (url != null && url.isNotBlank()) {
|
||||
if (!url.isNullOrBlank()) {
|
||||
append(" ($url)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import android.os.FileObserver
|
||||
import androidx.lifecycle.LiveData
|
||||
import dev.patrickgold.florisboard.appContext
|
||||
import dev.patrickgold.florisboard.assetManager
|
||||
import dev.patrickgold.florisboard.ime.dictionary.DictionaryExtension
|
||||
import dev.patrickgold.florisboard.ime.keyboard.KeyboardExtension
|
||||
import dev.patrickgold.florisboard.ime.nlp.LanguagePackExtension
|
||||
import dev.patrickgold.florisboard.ime.text.composing.Appender
|
||||
@@ -63,6 +64,7 @@ val ExtensionJsonConfig = Json {
|
||||
polymorphic(Extension::class) {
|
||||
subclass(KeyboardExtension::class, KeyboardExtension.serializer())
|
||||
subclass(ThemeExtension::class, ThemeExtension.serializer())
|
||||
subclass(DictionaryExtension::class, DictionaryExtension.serializer())
|
||||
subclass(LanguagePackExtension::class, LanguagePackExtension.serializer())
|
||||
}
|
||||
polymorphic(Composer::class) {
|
||||
@@ -70,7 +72,7 @@ val ExtensionJsonConfig = Json {
|
||||
subclass(HangulUnicode::class, HangulUnicode.serializer())
|
||||
subclass(KanaUnicode::class, KanaUnicode.serializer())
|
||||
subclass(WithRules::class, WithRules.serializer())
|
||||
default { Appender.serializer() }
|
||||
defaultDeserializer { Appender.serializer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +81,7 @@ class ExtensionManager(context: Context) {
|
||||
companion object {
|
||||
const val IME_KEYBOARD_PATH = "ime/keyboard"
|
||||
const val IME_THEME_PATH = "ime/theme"
|
||||
const val IME_DICTIONARY_PATH = "ime/dictionary"
|
||||
const val IME_LANGUAGEPACK_PATH = "ime/languagepack"
|
||||
|
||||
private const val FILE_OBSERVER_MASK =
|
||||
@@ -91,11 +94,13 @@ class ExtensionManager(context: Context) {
|
||||
|
||||
val keyboardExtensions = ExtensionIndex(KeyboardExtension.serializer(), IME_KEYBOARD_PATH)
|
||||
val themes = ExtensionIndex(ThemeExtension.serializer(), IME_THEME_PATH)
|
||||
val dictionaryExtensions = ExtensionIndex(DictionaryExtension.serializer(), IME_DICTIONARY_PATH)
|
||||
val languagePacks = ExtensionIndex(LanguagePackExtension.serializer(), IME_LANGUAGEPACK_PATH)
|
||||
|
||||
fun init() {
|
||||
keyboardExtensions.init()
|
||||
themes.init()
|
||||
dictionaryExtensions.init()
|
||||
languagePacks.init()
|
||||
}
|
||||
|
||||
@@ -105,6 +110,7 @@ class ExtensionManager(context: Context) {
|
||||
val relGroupPath = when (ext) {
|
||||
is KeyboardExtension -> IME_KEYBOARD_PATH
|
||||
is ThemeExtension -> IME_THEME_PATH
|
||||
is DictionaryExtension -> IME_DICTIONARY_PATH
|
||||
is LanguagePackExtension -> IME_LANGUAGEPACK_PATH
|
||||
else -> error("Unknown extension type")
|
||||
}
|
||||
@@ -131,6 +137,7 @@ class ExtensionManager(context: Context) {
|
||||
fun getExtensionById(id: String): Extension? {
|
||||
keyboardExtensions.value?.find { it.meta.id == id }?.let { return it }
|
||||
themes.value?.find { it.meta.id == id }?.let { return it }
|
||||
dictionaryExtensions.value?.find { it.meta.id == id }?.let { return it }
|
||||
languagePacks.value?.find { it.meta.id == id }?.let { return it }
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -143,8 +143,9 @@ object ZipUtils {
|
||||
val flexEntry = flexEntries.nextElement()
|
||||
val flexEntryFile = FsFile(dstDir, flexEntry.name)
|
||||
if (flexEntry.isDirectory) {
|
||||
flexEntryFile.mkdir()
|
||||
flexEntryFile.mkdirs()
|
||||
} else {
|
||||
flexEntryFile.parentDir?.mkdirs()
|
||||
flexFile.copy(flexEntry, flexEntryFile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.lib
|
||||
package dev.patrickgold.florisboard.native
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.serializer
|
||||
|
||||
/**
|
||||
* Type alias for a native pointer.
|
||||
@@ -31,31 +34,44 @@ const val NATIVE_NULLPTR: NativePtr = 0L
|
||||
/**
|
||||
* Type alias for a native string in standard UTF-8 encoding.
|
||||
*/
|
||||
typealias NativeStr = ByteBuffer
|
||||
typealias NativeStr = ByteArray
|
||||
|
||||
/**
|
||||
* Converts a native string to a Java string.
|
||||
*/
|
||||
fun NativeStr.toJavaString(): String {
|
||||
val bytes: ByteArray
|
||||
if (this.hasArray()) {
|
||||
bytes = this.array()
|
||||
} else {
|
||||
bytes = ByteArray(this.remaining())
|
||||
this.get(bytes)
|
||||
}
|
||||
return String(bytes, Charsets.UTF_8)
|
||||
return this.toString(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Java string to a native string.
|
||||
*/
|
||||
fun String.toNativeStr(): NativeStr {
|
||||
val bytes = this.toByteArray(Charsets.UTF_8)
|
||||
val buffer = ByteBuffer.allocateDirect(bytes.size)
|
||||
buffer.put(bytes)
|
||||
buffer.rewind()
|
||||
return buffer
|
||||
return this.toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Type alias for a serialized native list in standard UTF-8 encoding.
|
||||
*/
|
||||
typealias NativeList = ByteArray
|
||||
|
||||
/**
|
||||
* Converts a serialized native list to a Java list.
|
||||
*/
|
||||
inline fun <reified T> NativeList.toJavaList(): List<T> {
|
||||
return Json.decodeFromString(getListSerializer(), this.toJavaString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Java list to a serialized native list.
|
||||
*/
|
||||
inline fun <reified T> List<T>.toNativeList(): NativeList {
|
||||
return Json.encodeToString(getListSerializer(), this).toNativeStr()
|
||||
}
|
||||
|
||||
@PublishedApi
|
||||
internal inline fun <reified T> getListSerializer(): KSerializer<List<T>> {
|
||||
return ListSerializer(serializer())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:Suppress("unused")
|
||||
|
||||
package dev.patrickgold.florisboard.native
|
||||
|
||||
/**
|
||||
* Base exception class for all native exceptions.
|
||||
*
|
||||
* @param msg The error message describing the native exception.
|
||||
*
|
||||
* @see <a href="https://en.cppreference.com/w/cpp/error/exception">std::exception on cppreference.com</a>
|
||||
*/
|
||||
open class NativeException(msg: String) : Exception(msg)
|
||||
|
||||
/**
|
||||
* Exception class used to describe failed native allocations.
|
||||
*
|
||||
* @param msg The error message describing the failed native allocation.
|
||||
*
|
||||
* @see <a href="https://en.cppreference.com/w/cpp/memory/new/bad_alloc">std::bad_alloc on cppreference.com</a>
|
||||
*/
|
||||
open class NativeBadAlloc(msg: String) : NativeException(msg)
|
||||
|
||||
/**
|
||||
* Exception class used to describe failed array allocations to to incorrect lengths.
|
||||
*
|
||||
* @param msg The error message describing the failed native array allocation.
|
||||
*
|
||||
* @see <a href="https://en.cppreference.com/w/cpp/memory/new/bad_array_new_length">std::bad_array_new_length on
|
||||
* cppreference.com</a>
|
||||
*/
|
||||
open class NativeBadArrayNewLength(msg: String) : NativeBadAlloc(msg)
|
||||
|
||||
/**
|
||||
* Exception class used to describe failed native casts.
|
||||
*
|
||||
* @param msg The error message describing the failed native cast.
|
||||
*
|
||||
* @see <a href="https://en.cppreference.com/w/cpp/types/bad_cast">std::bad_cast on cppreference.com</a>
|
||||
*/
|
||||
open class NativeBadCast(msg: String) : NativeException(msg)
|
||||
|
||||
/**
|
||||
* Exception class used to describe failed native type id operators.
|
||||
*
|
||||
* @param msg The error message describing the failed native type id operator.
|
||||
*
|
||||
* @see <a href="https://en.cppreference.com/w/cpp/types/bad_typeid">std::bad_typeid on cppreference.com</a>
|
||||
*/
|
||||
open class NativeBadTypeid(msg: String) : NativeException(msg)
|
||||
|
||||
/**
|
||||
* Exception class which indicates violations of logical preconditions or class invariants.
|
||||
*
|
||||
* @param msg The error message describing the logic error.
|
||||
*
|
||||
* @see <a href="https://en.cppreference.com/w/cpp/error/logic_error">std::logic_error on cppreference.com</a>
|
||||
*/
|
||||
open class NativeLogicError(msg: String) : NativeException(msg)
|
||||
|
||||
/**
|
||||
* Exception class used to report invalid arguments.
|
||||
*
|
||||
* @param msg The error message describing the invalid argument.
|
||||
*
|
||||
* @see <a href="https://en.cppreference.com/w/cpp/error/invalid_argument">std::invalid_argument on cppreference.com</a>
|
||||
*/
|
||||
open class NativeInvalidArgument(msg: String) : NativeLogicError(msg)
|
||||
|
||||
/**
|
||||
* Exception class used to report domain errors.
|
||||
*
|
||||
* @param msg The error message describing the domain error.
|
||||
*
|
||||
* @see <a href="https://en.cppreference.com/w/cpp/error/domain_error">std::domain_error on cppreference.com</a>
|
||||
*/
|
||||
open class NativeDomainError(msg: String) : NativeLogicError(msg)
|
||||
|
||||
/**
|
||||
* Exception class used to report attempts to exceed the maximum allowed size.
|
||||
*
|
||||
* @param msg The error message describing the length error.
|
||||
*
|
||||
* @see <a href="https://en.cppreference.com/w/cpp/error/length_error">std::length_error on cppreference.com</a>
|
||||
*/
|
||||
open class NativeLengthError(msg: String) : NativeLogicError(msg)
|
||||
|
||||
/**
|
||||
* Exception class used to report arguments outside of the expected range.
|
||||
*
|
||||
* @param msg The error message describing the out-of-range argument.
|
||||
*
|
||||
* @see <a href="https://en.cppreference.com/w/cpp/error/out_of_range">std::out_of_range on cppreference.com</a>
|
||||
*/
|
||||
open class NativeOutOfRange(msg: String) : NativeLogicError(msg)
|
||||
|
||||
/**
|
||||
* Exception class used to indicate conditions only detectable at run time.
|
||||
*
|
||||
* @param msg The error message describing the runtime error.
|
||||
*
|
||||
* @see <a href="https://en.cppreference.com/w/cpp/error/runtime_error">std::runtime_error on cppreference.com</a>
|
||||
*/
|
||||
open class NativeRuntimeError(msg: String) : NativeException(msg)
|
||||
|
||||
/**
|
||||
* Exception class used to report range errors in internal computations.
|
||||
*
|
||||
* @param msg The error message describing the range error.
|
||||
*
|
||||
* @see <a href="https://en.cppreference.com/w/cpp/error/range_error">std::range_error on cppreference.com</a>
|
||||
*/
|
||||
open class NativeRangeError(msg: String) : NativeRuntimeError(msg)
|
||||
|
||||
/**
|
||||
* Exception class used to report arithmetic overflows.
|
||||
*
|
||||
* @param msg The error message describing the overflow error.
|
||||
*
|
||||
* @see <a href="https://en.cppreference.com/w/cpp/error/overflow_error">std::overflow_error on cppreference.com</a>
|
||||
*/
|
||||
open class NativeOverflowError(msg: String) : NativeRuntimeError(msg)
|
||||
|
||||
/**
|
||||
* Exception class used to report arithmetic underflows.
|
||||
*
|
||||
* @param msg The error message describing the underflow error.
|
||||
*
|
||||
* @see <a href="https://en.cppreference.com/w/cpp/error/underflow_error">std::underflow_error on cppreference.com</a>
|
||||
*/
|
||||
open class NativeUnderflowError(msg: String) : NativeRuntimeError(msg)
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.plugin
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import dev.patrickgold.florisboard.BuildConfig
|
||||
import dev.patrickgold.florisboard.lib.kotlin.guardedByLock
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class FlorisPluginIndexer(private val context: Context) {
|
||||
private val _pluginIndexFlow = MutableStateFlow(listOf<IndexedPlugin>())
|
||||
val pluginIndexFlow = _pluginIndexFlow.asStateFlow()
|
||||
val pluginIndex = guardedByLock { mutableListOf<IndexedPlugin>() }
|
||||
|
||||
suspend fun indexBoundServices() {
|
||||
val intent = Intent(FlorisPluginService.SERVICE_INTERFACE)
|
||||
val packageManager = context.packageManager
|
||||
val resolveInfoList = packageManager.queryIntentServices(intent, PackageManager.GET_META_DATA)
|
||||
|
||||
pluginIndex.withLock { pluginIndex ->
|
||||
val newPluginIndex = mutableListOf<IndexedPlugin>()
|
||||
|
||||
suspend fun registerPlugin(
|
||||
serviceName: ComponentName,
|
||||
state: IndexedPluginState,
|
||||
metadata: FlorisPluginMetadata = FlorisPluginMetadata(""),
|
||||
) {
|
||||
val plugin = pluginIndex.find { it.serviceName == serviceName }?.also {
|
||||
it.state = state
|
||||
it.metadata = metadata
|
||||
pluginIndex.remove(it)
|
||||
} ?: IndexedPlugin(context, serviceName, state, metadata)
|
||||
plugin.create()
|
||||
newPluginIndex.add(plugin)
|
||||
}
|
||||
|
||||
for (resolveInfo in resolveInfoList) {
|
||||
val serviceName = ComponentName(resolveInfo.serviceInfo.packageName, resolveInfo.serviceInfo.name)
|
||||
// TODO: hard-coding blocking third-party plugins for now
|
||||
if (serviceName.packageName != BuildConfig.APPLICATION_ID) {
|
||||
continue
|
||||
}
|
||||
val metadataBundle = resolveInfo.serviceInfo.metaData
|
||||
if (metadataBundle == null) {
|
||||
registerPlugin(serviceName, stateError(IndexedPluginError.NoMetadata))
|
||||
continue
|
||||
}
|
||||
val metadataXmlId = metadataBundle.getInt(FlorisPluginService.SERVICE_METADATA, -1)
|
||||
if (metadataXmlId == -1) {
|
||||
registerPlugin(serviceName, stateError(IndexedPluginError.NoMetadata))
|
||||
continue
|
||||
}
|
||||
val packageContext = context.createPackageContext(resolveInfo.serviceInfo.packageName, 0)
|
||||
try {
|
||||
val metadata = FlorisPluginMetadata.parseFromXml(packageContext, metadataXmlId)
|
||||
registerPlugin(serviceName, stateOk(), metadata)
|
||||
} catch (e: Exception) {
|
||||
registerPlugin(serviceName, stateError(IndexedPluginError.InvalidMetadata, e))
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy remaining plugins which are not present anymore
|
||||
for (oldPlugin in pluginIndex) {
|
||||
oldPlugin.destroy()
|
||||
}
|
||||
pluginIndex.clear()
|
||||
pluginIndex.addAll(newPluginIndex)
|
||||
_pluginIndexFlow.value = pluginIndex.toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun observeServiceChanges() {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent != null && intent.action == "android.intent.action.PACKAGE_CHANGED") {
|
||||
runBlocking { indexBoundServices() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.registerReceiver(receiver, IntentFilter("android.intent.action.PACKAGE_CHANGED"))
|
||||
}
|
||||
|
||||
suspend fun getOrNull(pluginId: String?): IndexedPlugin? {
|
||||
if (pluginId == null) return null
|
||||
return pluginIndex.withLock { pluginIndex ->
|
||||
pluginIndex.find { it.metadata.id == pluginId }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.plugin
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Message
|
||||
import android.os.Messenger
|
||||
import android.view.textservice.SuggestionsInfo
|
||||
import dev.patrickgold.florisboard.BuildConfig
|
||||
import dev.patrickgold.florisboard.ime.core.ComputedSubtype
|
||||
import dev.patrickgold.florisboard.ime.nlp.SpellingProvider
|
||||
import dev.patrickgold.florisboard.ime.nlp.SpellingResult
|
||||
import dev.patrickgold.florisboard.ime.nlp.SubtypeSupportInfo
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionCandidate
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionProvider
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionRequest
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionRequestFlags
|
||||
import dev.patrickgold.florisboard.lib.devtools.flogDebug
|
||||
import dev.patrickgold.florisboard.lib.io.FlorisRef
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class IndexedPlugin(
|
||||
val context: Context,
|
||||
val serviceName: ComponentName,
|
||||
var state: IndexedPluginState,
|
||||
var metadata: FlorisPluginMetadata = FlorisPluginMetadata(""),
|
||||
) : SpellingProvider, SuggestionProvider {
|
||||
private var messageIdGenerator = AtomicInteger(1)
|
||||
private var connection = IndexedPluginConnection(context)
|
||||
|
||||
override suspend fun create() {
|
||||
if (isValidAndBound()) return
|
||||
connection.bindService(serviceName)
|
||||
}
|
||||
|
||||
override suspend fun evaluateIsSupported(subtype: ComputedSubtype): SubtypeSupportInfo {
|
||||
val message = FlorisPluginMessage.requestToService(
|
||||
action = FlorisPluginMessage.ACTION_EVALUATE_IS_SUPPORTED,
|
||||
id = messageIdGenerator.getAndIncrement(),
|
||||
data = Json.encodeToString(subtype),
|
||||
)
|
||||
connection.sendMessage(message)
|
||||
return withTimeoutOrNull(5000L) {
|
||||
val replyMessage = connection.replyMessages.first { it.id == message.id }
|
||||
val resultData = replyMessage.data ?: return@withTimeoutOrNull null
|
||||
return@withTimeoutOrNull Json.decodeFromString(resultData)
|
||||
} ?: SubtypeSupportInfo.unsupported("!! Error in communication with plugin !!")
|
||||
}
|
||||
|
||||
override suspend fun preload(subtype: ComputedSubtype) {
|
||||
val message = FlorisPluginMessage.requestToService(
|
||||
action = FlorisPluginMessage.ACTION_PRELOAD,
|
||||
id = messageIdGenerator.getAndIncrement(),
|
||||
data = Json.encodeToString(ComputedSubtype.serializer(), subtype),
|
||||
)
|
||||
connection.sendMessage(message)
|
||||
}
|
||||
|
||||
override suspend fun spell(
|
||||
subtypeId: Long,
|
||||
word: String,
|
||||
prevWords: List<String>,
|
||||
flags: SuggestionRequestFlags,
|
||||
): SpellingResult {
|
||||
val request = SuggestionRequest(subtypeId, word, prevWords, flags)
|
||||
val message = FlorisPluginMessage.requestToService(
|
||||
action = FlorisPluginMessage.ACTION_SPELL,
|
||||
id = messageIdGenerator.getAndIncrement(),
|
||||
data = Json.encodeToString(request),
|
||||
)
|
||||
connection.sendMessage(message)
|
||||
return withTimeoutOrNull(5000L) {
|
||||
val replyMessage = connection.replyMessages.first { it.id == message.id }
|
||||
val resultObj = replyMessage.obj as? SuggestionsInfo ?: return@withTimeoutOrNull null
|
||||
return@withTimeoutOrNull SpellingResult(resultObj)
|
||||
} ?: SpellingResult.unspecified()
|
||||
}
|
||||
|
||||
override suspend fun suggest(
|
||||
subtypeId: Long,
|
||||
word: String,
|
||||
prevWords: List<String>,
|
||||
flags: SuggestionRequestFlags,
|
||||
): List<SuggestionCandidate> {
|
||||
val request = SuggestionRequest(subtypeId, word, prevWords, flags)
|
||||
val message = FlorisPluginMessage.requestToService(
|
||||
action = FlorisPluginMessage.ACTION_SUGGEST,
|
||||
id = messageIdGenerator.getAndIncrement(),
|
||||
data = Json.encodeToString(request),
|
||||
)
|
||||
connection.sendMessage(message)
|
||||
return withTimeoutOrNull(5000L) {
|
||||
val replyMessage = connection.replyMessages.first { it.id == message.id }
|
||||
val resultData = replyMessage.data ?: return@withTimeoutOrNull null
|
||||
return@withTimeoutOrNull Json.decodeFromString(resultData)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
override suspend fun notifySuggestionAccepted(subtypeId: Long, candidate: SuggestionCandidate) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun notifySuggestionReverted(subtypeId: Long, candidate: SuggestionCandidate) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun removeSuggestion(subtypeId: Long, candidate: SuggestionCandidate): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun destroy() {
|
||||
if (!isValidAndBound()) return
|
||||
connection.unbindService()
|
||||
}
|
||||
|
||||
fun packageContext(): Context {
|
||||
return context.createPackageContext(serviceName.packageName, 0)
|
||||
}
|
||||
|
||||
fun configurationRoute(): String? {
|
||||
if (!isValid()) return null
|
||||
val configurationRoute = metadata.settingsActivity ?: return null
|
||||
val ref = FlorisRef.from(configurationRoute)
|
||||
return if (ref.isAppUi) ref.relativePath else null
|
||||
}
|
||||
|
||||
fun settingsActivityIntent(): Intent? {
|
||||
if (!isValid()) return null
|
||||
val settingsActivityName = metadata.settingsActivity ?: return null
|
||||
val intent = Intent().also {
|
||||
it.component = ComponentName(serviceName.packageName, settingsActivityName)
|
||||
it.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
return if (intent.resolveActivityInfo(context.packageManager, 0) != null) {
|
||||
intent
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun isValid(): Boolean {
|
||||
return state == IndexedPluginState.Ok
|
||||
}
|
||||
|
||||
fun isValidAndBound(): Boolean {
|
||||
return isValid() && connection.isBound()
|
||||
}
|
||||
|
||||
fun isInternalPlugin(): Boolean {
|
||||
return serviceName.packageName == BuildConfig.APPLICATION_ID
|
||||
}
|
||||
|
||||
fun isExternalPlugin(): Boolean {
|
||||
return !isInternalPlugin()
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
val packageContext = packageContext()
|
||||
return """
|
||||
IndexedPlugin {
|
||||
serviceName=$serviceName
|
||||
state=$state
|
||||
isBound=${connection.isBound()}
|
||||
metadata=${metadata.toString(packageContext).prependIndent(" ").substring(16)}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
class IndexedPluginConnection(private val context: Context) {
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var serviceMessenger = MutableStateFlow<Messenger?>(null)
|
||||
private val consumerMessenger = Messenger(IncomingHandler())
|
||||
private var isBound = AtomicBoolean(false)
|
||||
private val stagedOutgoingMessages = MutableSharedFlow<FlorisPluginMessage>(
|
||||
replay = 8,
|
||||
extraBufferCapacity = 8,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
private val _replyMessages = MutableSharedFlow<FlorisPluginMessage>(
|
||||
replay = 8,
|
||||
extraBufferCapacity = 8,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
val replyMessages = _replyMessages.asSharedFlow()
|
||||
|
||||
private val serviceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
flogDebug { "$name, $binder" }
|
||||
if (name == null || binder == null) return
|
||||
|
||||
serviceMessenger.value = Messenger(binder)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
flogDebug { "$name" }
|
||||
if (name == null) return
|
||||
|
||||
serviceMessenger.value = null
|
||||
}
|
||||
|
||||
override fun onBindingDied(name: ComponentName?) {
|
||||
flogDebug { "$name" }
|
||||
if (name == null) return
|
||||
|
||||
serviceMessenger.value = null
|
||||
unbindService()
|
||||
bindService(name)
|
||||
}
|
||||
|
||||
override fun onNullBinding(name: ComponentName?) {
|
||||
flogDebug { "$name" }
|
||||
if (name == null) return
|
||||
|
||||
serviceMessenger.value = null
|
||||
unbindService()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
stagedOutgoingMessages.collect { message ->
|
||||
val messenger = serviceMessenger.first { it != null }!!
|
||||
messenger.send(message.also { it.replyTo = consumerMessenger }.toAndroidMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isBound(): Boolean {
|
||||
return isBound.get() && serviceMessenger.value != null
|
||||
}
|
||||
|
||||
fun bindService(serviceName: ComponentName) {
|
||||
if (isBound.getAndSet(true)) return
|
||||
val intent = Intent().also {
|
||||
it.component = serviceName
|
||||
it.putExtra(FlorisPluginService.CONSUMER_PACKAGE_NAME, BuildConfig.APPLICATION_ID)
|
||||
it.putExtra(FlorisPluginService.CONSUMER_VERSION_CODE, BuildConfig.VERSION_CODE)
|
||||
it.putExtra(FlorisPluginService.CONSUMER_VERSION_NAME, BuildConfig.VERSION_NAME)
|
||||
}
|
||||
context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
fun unbindService() {
|
||||
if (!isBound.getAndSet(false)) return
|
||||
context.unbindService(serviceConnection)
|
||||
}
|
||||
|
||||
fun sendMessage(message: FlorisPluginMessage) = runBlocking {
|
||||
stagedOutgoingMessages.emit(message)
|
||||
}
|
||||
|
||||
@SuppressLint("HandlerLeak")
|
||||
inner class IncomingHandler : Handler(context.mainLooper) {
|
||||
override fun handleMessage(msg: Message) {
|
||||
val message = FlorisPluginMessage.fromAndroidMessage(msg)
|
||||
val (source, type, _) = message.metadata()
|
||||
if (source != FlorisPluginMessage.SOURCE_SERVICE || type != FlorisPluginMessage.TYPE_RESPONSE) {
|
||||
return
|
||||
}
|
||||
runBlocking {
|
||||
_replyMessages.emit(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class IndexedPluginError {
|
||||
NoMetadata,
|
||||
InvalidMetadata,
|
||||
}
|
||||
|
||||
sealed class IndexedPluginState {
|
||||
object Ok : IndexedPluginState()
|
||||
data class Error(val type: IndexedPluginError, val exception: Exception?) : IndexedPluginState()
|
||||
|
||||
override fun toString(): String {
|
||||
return when (this) {
|
||||
is Ok -> "Ok"
|
||||
is Error -> "Error($type, $exception)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun stateOk() = IndexedPluginState.Ok
|
||||
|
||||
internal fun stateError(type: IndexedPluginError, exception: Exception? = null) =
|
||||
IndexedPluginState.Error(type, exception)
|
||||
@@ -1,4 +1,4 @@
|
||||
<resources>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
|
||||
<string name="app_name">FlorisBoard</string>
|
||||
|
||||
<string name="key__phone_pause" comment="Label for the Pause key in the telephone keyboard layout">Pause</string>
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
<string name="settings__localization__title" comment="Title of languages and Layout screen">Languages & Layouts</string>
|
||||
<string name="settings__localization__display_language_names_in__label" comment="Label of Display language names in preference">Display language names in</string>
|
||||
<string name="settings__localization__group_subtypes__label" comment="Label of subtypes group">Subtypes</string>
|
||||
<string name="settings__localization__group_subtypes__label" comment="Label of subtypes group">Your subtypes</string>
|
||||
<string name="settings__localization__subtype_add_title" comment="Title of subtype dialog when adding a new subtype">Add subtype</string>
|
||||
<string name="settings__localization__language_pack_title" comment="Title of the language pack manager screen for managing installed and custom language packs">Manage installed language packs</string>
|
||||
<string name="settings__localization__language_pack_summary" comment="Summary of preference item for adding a new language pack">Experimental: manage extensions that add support for specific languages (shape-based Chinese input for now)</string>
|
||||
@@ -126,12 +126,15 @@
|
||||
<string name="settings__localization__suggested_subtype_presets_none_found" comment="Suggested presets none found">No suggested presets available. Use below button to view all subtype presets.</string>
|
||||
<string name="settings__localization__subtype_presets" comment="Subtype presets dialog title">Subtype presets</string>
|
||||
<string name="settings__localization__subtype_presets_view_all" comment="View all presets button">Show all</string>
|
||||
<string name="settings__localization__subtype_no_subtypes_configured_warning" comment="Warning message that no subtype has been defined">It seems that you haven\'t configured any subtypes. As a fallback the subtype English/QWERTY will be used!</string>
|
||||
<string name="settings__localization__subtype_no_subtypes_configured_warning" comment="Warning message that no subtype has been defined">It seems that you haven\'t configured any language subtypes. As a fallback the language subtype English/QWERTY will be used!</string>
|
||||
<string name="settings__localization__subtype_error_already_exists" comment="Error message shown in subtype dialog when a subtype to add already exists">This subtype already exists!</string>
|
||||
<string name="settings__localization__subtype_error_fields_no_value" comment="Error message shown in subtype editor if at least one field is set to '- select -' (means no value specified)">At least one field does not have a value selected. Please choose a value for the field(s).</string>
|
||||
<string name="settings__localization__subtype_error_layout_not_installed" comment="Error message shown in subtype list when a layout is not installed, where %s will be replaced by the layout ID">{layout_id} (not installed)</string>
|
||||
<string name="settings__localization__group_layouts__label" comment="Label of layouts group">Layouts</string>
|
||||
|
||||
<string name="latin_language_provider__title">Latin language provider</string>
|
||||
<string name="latin_language_provider__short_description">Provides word prediction and spell check support for Latin-script based languages.</string>
|
||||
|
||||
<string name="settings__theme__title" comment="Title of the Theme screen">Theme</string>
|
||||
<string name="pref__theme__mode__label" comment="Label of the theme mode preference">Theme mode</string>
|
||||
<string name="pref__theme__sunrise_time__label" comment="Label of the sunrise time preference">Sunrise time</string>
|
||||
@@ -610,6 +613,7 @@
|
||||
<string name="ext__meta__homepage">Homepage</string>
|
||||
<string name="ext__meta__id">ID</string>
|
||||
<string name="ext__meta__issue_tracker">Issue tracker</string>
|
||||
<string name="ext__meta__privacy_policy">Privacy policy</string>
|
||||
<string name="ext__meta__keywords">Keywords</string>
|
||||
<string name="ext__meta__label">Label</string>
|
||||
<string name="ext__meta__license">License</string>
|
||||
@@ -644,6 +648,7 @@
|
||||
<string name="ext__import__ext_any" comment="Title of Importer screen for import of any supported FlorisBoard extension">Import extension</string>
|
||||
<string name="ext__import__ext_keyboard" comment="Title of Importer screen for keyboard extension import">Import keyboard extension</string>
|
||||
<string name="ext__import__ext_theme" comment="Title of Importer screen for theme extension import">Import theme extension</string>
|
||||
<string name="ext__import__ext_dictionary" comment="Title of Importer screen for dictionary extension import">Import dictionary extension</string>
|
||||
<string name="ext__import__ext_languagepack" comment="Title of Importer screen for language pack extension import">Import language pack extension</string>
|
||||
<string name="ext__import__file_skip" comment="Label when a file cannot be imported in the current context. The actual reason string is in a separate text view below this string.">File can not be imported. Reason:</string>
|
||||
<string name="ext__import__file_skip_unsupported" comment="Reason string when file is unsupported">Unsupported or unrecognized file type.</string>
|
||||
@@ -660,6 +665,7 @@
|
||||
<string name="action__apply">Apply</string>
|
||||
<string name="action__back_up">Back up</string>
|
||||
<string name="action__cancel">Cancel</string>
|
||||
<string name="action__configure">Configure</string>
|
||||
<string name="action__create">Create</string>
|
||||
<string name="action__default">Default</string>
|
||||
<string name="action__delete">Delete</string>
|
||||
@@ -671,6 +677,8 @@
|
||||
<string name="action__edit">Edit</string>
|
||||
<string name="action__export">Export</string>
|
||||
<string name="action__import">Import</string>
|
||||
<string name="action__install">Install</string>
|
||||
<string name="action__info">Info</string>
|
||||
<string name="action__no">No</string>
|
||||
<string name="action__ok">OK</string>
|
||||
<string name="action__restore">Restore</string>
|
||||
@@ -680,6 +688,8 @@
|
||||
<string name="action__select_dirs">Select directories</string>
|
||||
<string name="action__select_file">Select file</string>
|
||||
<string name="action__select_files">Select files</string>
|
||||
<string name="action__share">Share</string>
|
||||
<string name="action__uninstall">Uninstall</string>
|
||||
<string name="action__yes">Yes</string>
|
||||
|
||||
<!-- Error strings (generic) -->
|
||||
|
||||
20
app/src/main/res/xml/latin_language_provider.xml
Normal file
20
app/src/main/res/xml/latin_language_provider.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<plugin xmlns:fl="https://schemas.florisboard.org/plugin"
|
||||
fl:id="org.florisboard.plugins.nlp.latin"
|
||||
fl:version="0.4.0"
|
||||
fl:title="@string/latin_language_provider__title"
|
||||
fl:shortDescription="@string/latin_language_provider__short_description"
|
||||
fl:maintainers="Patrick Goldinger <patrick@patrickgold.dev>"
|
||||
fl:homepage="@string/florisboard__repo_url"
|
||||
fl:issueTracker="@string/florisboard__issue_tracker_url"
|
||||
fl:privacyPolicy="@string/florisboard__privacy_policy_url"
|
||||
fl:license="Apache-2.0"
|
||||
fl:settingsActivity="florisboard://app-ui/settings/localization/manage-dictionaries">
|
||||
|
||||
<!-- Spelling config -->
|
||||
<spelling/>
|
||||
|
||||
<!-- Suggestion config -->
|
||||
<suggestion fl:requireAlwaysEnabled="false"/>
|
||||
|
||||
</plugin>
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.agp.application) apply false
|
||||
alias(libs.plugins.agp.library) apply false
|
||||
alias(libs.plugins.agp.test) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
|
||||
@@ -9,3 +9,13 @@ kotlin.code.style=official
|
||||
org.gradle.jvmargs=-Xmx4096m
|
||||
org.gradle.parallel=true
|
||||
org.gradle.warning.mode=all
|
||||
|
||||
### FlorisBoard-specific flags ###
|
||||
|
||||
projectCompileSdk=33
|
||||
projectMinSdk=24
|
||||
projectTargetSdk=33
|
||||
|
||||
projectVersionCode=90
|
||||
projectVersionName=0.4.0
|
||||
projectVersionNameSuffix=-alpha04
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
accompanist = "0.30.1"
|
||||
android-gradle-plugin = "8.1.1"
|
||||
androidx-activity = "1.5.1"
|
||||
androidx-annotation = "1.6.0"
|
||||
androidx-autofill = "1.1.0"
|
||||
androidx-collection = "1.2.0"
|
||||
androidx-compose = "1.4.3"
|
||||
@@ -39,6 +40,7 @@ accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayo
|
||||
accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||
androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" }
|
||||
androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" }
|
||||
androidx-autofill = { module = "androidx.autofill:autofill", version.ref = "androidx-autofill" }
|
||||
androidx-collection-ktx = { module = "androidx.collection:collection-ktx", version.ref = "androidx-collection" }
|
||||
androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-compose" }
|
||||
@@ -78,6 +80,7 @@ kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref
|
||||
[plugins]
|
||||
# Main
|
||||
agp-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }
|
||||
agp-library = { id = "com.android.library", version.ref = "android-gradle-plugin" }
|
||||
agp-test = { id = "com.android.test", version.ref = "android-gradle-plugin" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
|
||||
73
ime-lib/build.gradle.kts
Normal file
73
ime-lib/build.gradle.kts
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Suppress needed until https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
|
||||
@file:Suppress("DSL_SCOPE_VIOLATION")
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.agp.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
val projectCompileSdk: String by project
|
||||
val projectMinSdk: String by project
|
||||
val projectVersionName: String by project
|
||||
val projectVersionNameSuffix: String by project
|
||||
|
||||
android {
|
||||
namespace = "dev.patrickgold.florisboard.ime"
|
||||
compileSdk = projectCompileSdk.toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = projectMinSdk.toInt()
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
freeCompilerArgs = listOf(
|
||||
"-opt-in=kotlin.contracts.ExperimentalContracts",
|
||||
)
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
maybeCreate("main").apply {
|
||||
java.srcDir("src/main/kotlin")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.annotation)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
/*val sourcesJar = tasks.register<Jar>("sourcesJar") {
|
||||
archiveClassifier.set("sources")
|
||||
from(android.sourceSets.getByName("main").java.srcDirs)
|
||||
}
|
||||
|
||||
mavenPublishing {
|
||||
coordinates(projectGroupId, artifactId, projectVersion)
|
||||
}*/
|
||||
17
ime-lib/src/main/AndroidManifest.xml
Normal file
17
ime-lib/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2023 Patrick Goldinger
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
<manifest />
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.core
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Data class which represents a computed user-specified set of language and layout.
|
||||
*
|
||||
* @property id The ID of this subtype.
|
||||
* @property primaryLocale The primary locale tag of this subtype.
|
||||
* @property secondaryLocales The secondary locale tags of this subtype. May be an empty list.
|
||||
*/
|
||||
@Serializable
|
||||
data class ComputedSubtype(
|
||||
val id: Long,
|
||||
val primaryLocale: String,
|
||||
val secondaryLocales: List<String>,
|
||||
) {
|
||||
fun isFallback(): Boolean {
|
||||
return id < 0
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -18,6 +18,9 @@ package dev.patrickgold.florisboard.ime.input
|
||||
|
||||
/**
|
||||
* Enum for the input shift states of a text keyboard.
|
||||
*
|
||||
* Note: This class MUST be kept in sync with the C++ implementation:
|
||||
* https://github.com/florisboard/nlp/blob/main/nlpcore/src/common/suggestion.cppm
|
||||
*/
|
||||
enum class InputShiftState(val value: Int) {
|
||||
/**
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.nlp
|
||||
|
||||
import dev.patrickgold.florisboard.ime.core.ComputedSubtype
|
||||
|
||||
/**
|
||||
* Base interface for any NLP provider implementation. NLP providers maintain their own internal state and only receive
|
||||
* limited events, such as [create], [preload], [destroy] and group specific requests.
|
||||
*
|
||||
* Providers should NEVER do heavy work in the initialization phase of the object, any first-time setup work should be
|
||||
* exclusively done in [preload].
|
||||
*/
|
||||
interface NlpProvider {
|
||||
/**
|
||||
* Is called exactly once before any [preload] or task specific requests, which allows to make one-time setups, set
|
||||
* up necessary native bindings, threads, etc.
|
||||
*/
|
||||
suspend fun create()
|
||||
|
||||
/**
|
||||
* Is called to check if the language provider supports the subtype using its primary/secondary language options.
|
||||
*
|
||||
* @param subtype Information about the subtype to check, primarily used for getting the primary and secondary
|
||||
* language for correct dictionary selection.
|
||||
* @return true if this provider is able to provide NLP services for given subtype, false otherwise.
|
||||
*/
|
||||
suspend fun evaluateIsSupported(subtype: ComputedSubtype): SubtypeSupportInfo
|
||||
|
||||
/**
|
||||
* Is called at least once before a task specific request occurs, to allow for locale-specific preloading of
|
||||
* dictionaries and language models.
|
||||
*
|
||||
* @param subtype Information about the subtype to preload, primarily used for getting the primary and secondary
|
||||
* language for correct dictionary selection.
|
||||
*/
|
||||
suspend fun preload(subtype: ComputedSubtype)
|
||||
|
||||
/**
|
||||
* Is called when the provider is no longer needed and should be destroyed. Any native allocations should be freed
|
||||
* up and any asynchronous tasks/threads must be stopped. After this method call finishes, this provider object is
|
||||
* considered dead and will be queued to be cleaned up by the GC in the next round.
|
||||
*/
|
||||
suspend fun destroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for an NLP provider specializing in spell check services.
|
||||
*/
|
||||
interface SpellingProvider : NlpProvider {
|
||||
/**
|
||||
* Spell check given [word] in the primary (and optionally secondary if defined) language of given [subtypeId], and
|
||||
* return a spelling result. If the given word is spelled correctly, a spelling result with no suggestions should
|
||||
* be returned.
|
||||
*
|
||||
* Spell check requests are considered to be read-only and should at no point be used to train the underlying
|
||||
* language model and/or weights in the dictionary.
|
||||
*
|
||||
* @param subtypeId THe ID of the subtype this request is for, is guaranteed to match one of the subtype IDs which
|
||||
* have been passed to [preload].
|
||||
* @param word The word to spell check, may contain any valid Unicode code point.
|
||||
* @param prevWords List of preceding words, which allows for a more context-based spellcheck. This list can
|
||||
* also be empty, if no surrounding context can be provided.
|
||||
* @param flags The suggestion request flags.
|
||||
*
|
||||
* @return A spelling result object, which indicates both the validity of this word and if needed suggested
|
||||
* corrections for the misspelled word.
|
||||
*/
|
||||
suspend fun spell(
|
||||
subtypeId: Long,
|
||||
word: String,
|
||||
prevWords: List<String>,
|
||||
flags: SuggestionRequestFlags,
|
||||
): SpellingResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for an NLP provider specializing in next/current-word suggestion and autocorrect services.
|
||||
*/
|
||||
interface SuggestionProvider : NlpProvider {
|
||||
/**
|
||||
* Callback from the editor logic that the editor content has changed and that new suggestions should be generated
|
||||
* for the new user input. There is no guarantee that candidates returned are actually used, as there may be sudden
|
||||
* context changes or clipboard/emoji suggestions overriding the results (if the user has those enabled).
|
||||
*
|
||||
* @param subtypeId THe ID of the subtype this request is for, is guaranteed to match one of the subtype IDs which
|
||||
* have been passed to [preload].
|
||||
* @param word The current word to use as a base for word prediction, may contain any valid Unicode code point.
|
||||
* @param prevWords List of preceding words, which allows for a more context-based word prediction. This list
|
||||
* can also be empty, if no surrounding context can be provided.
|
||||
* @param flags The suggestion request flags.
|
||||
*
|
||||
* @return A list of candidate suggestions for the current editor content state, complying with the max count
|
||||
* restrictions as best as possible. If the provider cannot at all provide any candidates, an empty list should be
|
||||
* returned, in which case the UI automatically adapts and shows alternative actions.
|
||||
*/
|
||||
suspend fun suggest(
|
||||
subtypeId: Long,
|
||||
word: String,
|
||||
prevWords: List<String>,
|
||||
flags: SuggestionRequestFlags,
|
||||
): List<SuggestionCandidate>
|
||||
|
||||
/**
|
||||
* Is called when a suggestion has been accepted, either manually by the user or automatically through auto-commit.
|
||||
* This is purely a notification about an event and can safely be ignored if not needed.
|
||||
*
|
||||
* @param subtypeId THe ID of the subtype this request is for, is guaranteed to match one of the subtype IDs which
|
||||
* have been passed to [preload].
|
||||
* @param candidate The exact suggestion candidate which has been accepted.
|
||||
*/
|
||||
suspend fun notifySuggestionAccepted(subtypeId: Long, candidate: SuggestionCandidate)
|
||||
|
||||
/**
|
||||
* Is called when a previously automatically accepted suggestion has been reverted by the user with backspace. This
|
||||
* is purely a notification about an event and can safely be ignored if not needed.
|
||||
*
|
||||
* @param subtypeId THe ID of the subtype this request is for, is guaranteed to match one of the subtype IDs which
|
||||
* have been passed to [preload].
|
||||
* @param candidate The exact suggestion candidate which has been reverted.
|
||||
*/
|
||||
suspend fun notifySuggestionReverted(subtypeId: Long, candidate: SuggestionCandidate)
|
||||
|
||||
/**
|
||||
* Called if the user requests to prevent a certain suggested word from showing again. It is up to the actual
|
||||
* implementation to adhere to this user request, this removal is not enforced nor monitored by the NLP manager.
|
||||
*
|
||||
* @param subtypeId THe ID of the subtype this request is for, is guaranteed to match one of the subtype IDs which
|
||||
* have been passed to [preload].
|
||||
* @param candidate The exact suggestion candidate which the user does not want to see again.
|
||||
*
|
||||
* @return True if the removal request is supported and is accepted, false otherwise.
|
||||
*/
|
||||
suspend fun removeSuggestion(subtypeId: Long, candidate: SuggestionCandidate): Boolean
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package dev.patrickgold.florisboard.ime.nlp
|
||||
|
||||
import android.os.Build
|
||||
import android.view.textservice.SuggestionsInfo
|
||||
import dev.patrickgold.florisboard.lib.android.AndroidVersion
|
||||
|
||||
@@ -54,7 +55,7 @@ value class SpellingResult(val suggestionsInfo: SuggestionsInfo) {
|
||||
* provided corrections.
|
||||
*/
|
||||
val isGrammarError: Boolean
|
||||
get() = AndroidVersion.ATLEAST_API31_S &&
|
||||
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
suggestionsInfo.suggestionsAttributes and SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_GRAMMAR_ERROR != 0
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.nlp
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SubtypeSupportInfo(
|
||||
val state: SubtypeSupportState,
|
||||
val reason: String?,
|
||||
) {
|
||||
fun isFullySupported(): Boolean {
|
||||
return state == SubtypeSupportState.FullySupported
|
||||
}
|
||||
|
||||
fun isPartiallySupported(): Boolean {
|
||||
return state == SubtypeSupportState.PartiallySupported
|
||||
}
|
||||
|
||||
fun isUnsupported(): Boolean {
|
||||
return state == SubtypeSupportState.Unsupported
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fullySupported(): SubtypeSupportInfo {
|
||||
return SubtypeSupportInfo(SubtypeSupportState.FullySupported, null)
|
||||
}
|
||||
|
||||
fun partiallySupported(reason: String? = null): SubtypeSupportInfo {
|
||||
return SubtypeSupportInfo(SubtypeSupportState.PartiallySupported, reason)
|
||||
}
|
||||
|
||||
fun unsupported(reason: String? = null): SubtypeSupportInfo {
|
||||
return SubtypeSupportInfo(SubtypeSupportState.Unsupported, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SubtypeSupportState {
|
||||
FullySupported,
|
||||
PartiallySupported,
|
||||
Unsupported;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -16,16 +16,15 @@
|
||||
|
||||
package dev.patrickgold.florisboard.ime.nlp
|
||||
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.ime.clipboard.provider.ClipboardItem
|
||||
import dev.patrickgold.florisboard.ime.clipboard.provider.ItemType
|
||||
import dev.patrickgold.florisboard.lib.util.NetworkUtils
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
|
||||
/**
|
||||
* Interface for a candidate item, which is returned by a suggestion provider and used by the UI logic to render
|
||||
* the candidate row.
|
||||
*/
|
||||
interface SuggestionCandidate {
|
||||
@Serializable
|
||||
open class SuggestionCandidate(
|
||||
/**
|
||||
* Required primary text of a candidate item, must be non-null and non-blank. The value of this property will
|
||||
* be committed to the target editor when the user clicks on this candidate item (either replacing the current
|
||||
@@ -34,7 +33,7 @@ interface SuggestionCandidate {
|
||||
* In the UI it will be shown as the main label of a candidate item. Long texts that don't fit the maximum
|
||||
* candidate item width may be shortened and ellipsized.
|
||||
*/
|
||||
val text: CharSequence
|
||||
val text: String,
|
||||
|
||||
/**
|
||||
* Optional secondary text of a candidate item, can be used to provide additional context, e.g. for translation
|
||||
@@ -44,14 +43,14 @@ interface SuggestionCandidate {
|
||||
* is a non-null and non-blank character sequence. Long texts that don't fit the maximum candidate item width
|
||||
* may be shortened and ellipsized.
|
||||
*/
|
||||
val secondaryText: CharSequence?
|
||||
val secondaryText: String? = null,
|
||||
|
||||
/**
|
||||
* The confidence of this suggestion to be what the user wanted to type. Must be a value between 0.0 and 1.0 (both
|
||||
* inclusive), where 0.0 means no confidence and 1.0 means highest confidence. The confidence rating may be used to
|
||||
* sort and filter candidates if multiple providers provide suggestions for a single input.
|
||||
*/
|
||||
val confidence: Double
|
||||
val confidence: Double = 0.0,
|
||||
|
||||
/**
|
||||
* If true, it indicates that this candidate item should be automatically committed to the target editor once the
|
||||
@@ -63,14 +62,14 @@ interface SuggestionCandidate {
|
||||
* Only set this property to true if the algorithm has a high confidence that this suggestion is what the user
|
||||
* wanted to type.
|
||||
*/
|
||||
val isEligibleForAutoCommit: Boolean
|
||||
val isEligibleForAutoCommit: Boolean = false,
|
||||
|
||||
/**
|
||||
* If true, it indicates that this candidate item should be user removable (by long-pressing). This flag should
|
||||
* only be set if it actually makes sense for this type of candidate to be removable and if the linked source
|
||||
* provider supports this action.
|
||||
*/
|
||||
val isEligibleForUserRemoval: Boolean
|
||||
val isEligibleForUserRemoval: Boolean = true,
|
||||
|
||||
/**
|
||||
* Optional icon ID for showing an icon on the start of the candidate item. Mainly used for special suggestions
|
||||
@@ -80,60 +79,14 @@ interface SuggestionCandidate {
|
||||
* In the UI, if the ID is non-null, it will be shown to the start of the main label and scaled accordingly.
|
||||
* The color of the icon is entirely decided by the theme of the user. Icons that are monochrome work best.
|
||||
*/
|
||||
val iconId: Int?
|
||||
@Transient
|
||||
val iconId: Int? = null,
|
||||
|
||||
/**
|
||||
* The source provider of this candidate. Is used for several callbacks for training, blacklisting of candidates on
|
||||
* user-request, and so on. If null, it means that the source provider is unknown or does not want to receive
|
||||
* callbacks.
|
||||
*/
|
||||
val sourceProvider: SuggestionProvider?
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation for a word candidate (autocorrect and next/current word suggestion).
|
||||
*
|
||||
* @see SuggestionCandidate
|
||||
*/
|
||||
data class WordSuggestionCandidate(
|
||||
override val text: CharSequence,
|
||||
override val secondaryText: CharSequence? = null,
|
||||
override val confidence: Double = 0.0,
|
||||
override val isEligibleForAutoCommit: Boolean = false,
|
||||
override val isEligibleForUserRemoval: Boolean = true,
|
||||
override val sourceProvider: SuggestionProvider? = null,
|
||||
) : SuggestionCandidate {
|
||||
override val iconId: Int? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation for a clipboard candidate. Should generally not be used by a suggestion provider, except by
|
||||
* the clipboard suggestion provider.
|
||||
*
|
||||
* @see SuggestionCandidate
|
||||
*/
|
||||
data class ClipboardSuggestionCandidate(
|
||||
val clipboardItem: ClipboardItem,
|
||||
override val sourceProvider: SuggestionProvider?,
|
||||
) : SuggestionCandidate {
|
||||
override val text: CharSequence = clipboardItem.stringRepresentation()
|
||||
|
||||
override val secondaryText: CharSequence? = null
|
||||
|
||||
override val confidence: Double = 1.0
|
||||
|
||||
override val isEligibleForAutoCommit: Boolean = false
|
||||
|
||||
override val isEligibleForUserRemoval: Boolean = true
|
||||
|
||||
override val iconId: Int = when (clipboardItem.type) {
|
||||
ItemType.TEXT -> when {
|
||||
NetworkUtils.isEmailAddress(text) -> R.drawable.ic_email
|
||||
NetworkUtils.isUrl(text) -> R.drawable.ic_link
|
||||
NetworkUtils.isPhoneNumber(text) -> R.drawable.ic_phone
|
||||
else -> R.drawable.ic_assignment
|
||||
}
|
||||
ItemType.IMAGE -> R.drawable.ic_image
|
||||
ItemType.VIDEO -> R.drawable.ic_videocam
|
||||
}
|
||||
}
|
||||
@Transient
|
||||
val sourceProvider: SuggestionProvider? = null,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.nlp
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SuggestionRequest(
|
||||
val subtypeId: Long,
|
||||
val word: String,
|
||||
val prevWords: List<String>,
|
||||
val flags: SuggestionRequestFlags,
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.nlp
|
||||
|
||||
import dev.patrickgold.florisboard.ime.input.InputShiftState
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
|
||||
/**
|
||||
* Class which allows to read 31-bit binary suggestion request flags. Note that the signed bit MUST always be 0, else
|
||||
* the behavior of this class is undefined.
|
||||
*
|
||||
* Layout of the binary flags:
|
||||
* | Byte 3 | Byte 2 | Byte 1 | Byte 0 |
|
||||
* |--------|--------|--------|--------|
|
||||
* |0 | | |11111111| Maximum suggestion count (1-255), 0 indicating no limit.
|
||||
* |0 | | 1111| | Maximum ngram level (2-15). Values 0 and 1 cause word history to be ignored.
|
||||
* |0 | | 11 | | Input shift state (0-3) at the start of the current word.
|
||||
* |0 | |11 | | Input shift state (0-3) at the current cursor position.
|
||||
* |0 | 1| | | Flag indicating if possibly offensive words should be suggested.
|
||||
* |0 | 1 | | | Flag indicating if user-hidden words should still be displayed.
|
||||
* |0 | 1 | | | Flag indicating if the current request is within a private session.
|
||||
* |01111111|11111 | | | Reserved for future use.
|
||||
*
|
||||
* Note: This class MUST be kept in sync with the C++ implementation:
|
||||
* https://github.com/florisboard/nlp/blob/main/nlpcore/src/common/suggestion.cppm
|
||||
*/
|
||||
@Serializable(with = SuggestionRequestFlags.Serializer::class)
|
||||
@JvmInline
|
||||
value class SuggestionRequestFlags(val flags: Int) {
|
||||
companion object {
|
||||
const val M_MAX_SUGGESTION_COUNT = 0x000000FF
|
||||
val O_MAX_SUGGESTION_COUNT = M_MAX_SUGGESTION_COUNT.countTrailingZeroBits()
|
||||
const val M_MAX_NGRAM_LEVEL = 0x00000F00
|
||||
val O_MAX_NGRAM_LEVEL = M_MAX_NGRAM_LEVEL.countTrailingZeroBits()
|
||||
const val M_INPUT_SHIFT_STATE_START = 0x00003000
|
||||
val O_INPUT_SHIFT_STATE_START = M_INPUT_SHIFT_STATE_START.countTrailingZeroBits()
|
||||
const val M_INPUT_SHIFT_STATE_CURRENT = 0x0000C000
|
||||
val O_INPUT_SHIFT_STATE_CURRENT = M_INPUT_SHIFT_STATE_CURRENT.countTrailingZeroBits()
|
||||
const val F_ALLOW_POSSIBLY_OFFENSIVE = 0x00010000
|
||||
const val F_OVERRIDE_HIDDEN_FLAG = 0x00020000
|
||||
const val F_IS_PRIVATE_SESSION = 0x00040000
|
||||
|
||||
fun new(
|
||||
maxSuggestionCount: Int,
|
||||
maxNgramLevel: Int,
|
||||
issStart: InputShiftState,
|
||||
issCurrent: InputShiftState,
|
||||
allowPossiblyOffensive: Boolean,
|
||||
overrideHiddenFlag: Boolean,
|
||||
isPrivateSession: Boolean,
|
||||
): SuggestionRequestFlags {
|
||||
val flags = ((maxSuggestionCount shl O_MAX_SUGGESTION_COUNT) and M_MAX_SUGGESTION_COUNT) or
|
||||
((maxNgramLevel shl O_MAX_NGRAM_LEVEL) and M_MAX_NGRAM_LEVEL) or
|
||||
((issStart.toInt() shl O_INPUT_SHIFT_STATE_START) and M_INPUT_SHIFT_STATE_START) or
|
||||
((issCurrent.toInt() shl O_INPUT_SHIFT_STATE_CURRENT) and M_INPUT_SHIFT_STATE_CURRENT) or
|
||||
(if (allowPossiblyOffensive) F_ALLOW_POSSIBLY_OFFENSIVE else 0) or
|
||||
(if (overrideHiddenFlag) F_OVERRIDE_HIDDEN_FLAG else 0) or
|
||||
(if (isPrivateSession) F_IS_PRIVATE_SESSION else 0)
|
||||
return SuggestionRequestFlags(flags)
|
||||
}
|
||||
}
|
||||
|
||||
fun maxSuggestionCount(): Int {
|
||||
return (flags and M_MAX_SUGGESTION_COUNT) shr O_MAX_SUGGESTION_COUNT
|
||||
}
|
||||
|
||||
fun maxNgramLevel(): Int {
|
||||
return (flags and M_MAX_NGRAM_LEVEL) shr O_MAX_NGRAM_LEVEL
|
||||
}
|
||||
|
||||
fun inputShiftStateStart(): InputShiftState {
|
||||
return InputShiftState.values()[(flags and M_INPUT_SHIFT_STATE_START) shr O_INPUT_SHIFT_STATE_START]
|
||||
}
|
||||
|
||||
fun inputShiftStateCurrent(): InputShiftState {
|
||||
return InputShiftState.values()[(flags and M_INPUT_SHIFT_STATE_CURRENT) shr O_INPUT_SHIFT_STATE_CURRENT]
|
||||
}
|
||||
|
||||
fun allowPossiblyOffensive(): Boolean {
|
||||
return (flags and F_ALLOW_POSSIBLY_OFFENSIVE) != 0
|
||||
}
|
||||
|
||||
fun overrideHiddenFlag(): Boolean {
|
||||
return (flags and F_OVERRIDE_HIDDEN_FLAG) != 0
|
||||
}
|
||||
|
||||
fun isPrivateSession(): Boolean {
|
||||
return (flags and F_IS_PRIVATE_SESSION) != 0
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "SuggestionRequestFlags { flags = 0x${flags.toString(16)} }"
|
||||
}
|
||||
|
||||
fun toInt(): Int {
|
||||
return flags
|
||||
}
|
||||
|
||||
object Serializer : KSerializer<SuggestionRequestFlags> {
|
||||
override val descriptor = PrimitiveSerialDescriptor("SuggestionRequestFlags", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: SuggestionRequestFlags) {
|
||||
encoder.encodeInt(value.toInt())
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): SuggestionRequestFlags {
|
||||
return SuggestionRequestFlags(decoder.decodeInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.lib.android
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Parcelable
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
fun <T : Parcelable> Intent.getParcelableExtraAnyApi(name: String, klass: KClass<T>): T? {
|
||||
return if (AndroidVersion.ATLEAST_API33_T) {
|
||||
getParcelableExtra(name, klass.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getParcelableExtra(name)
|
||||
}
|
||||
}
|
||||
@@ -17,42 +17,52 @@
|
||||
package dev.patrickgold.florisboard.lib.android
|
||||
|
||||
import android.os.Build
|
||||
import androidx.annotation.ChecksSdkIntAtLeast
|
||||
|
||||
@Suppress("unused")
|
||||
object AndroidVersion {
|
||||
/** Android 7.1 **/
|
||||
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N_MR1)
|
||||
inline val ATLEAST_API25_N_MR1 get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1
|
||||
inline val ATMOST_API25_N_MR1 get() = Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1
|
||||
|
||||
/** Android 8 **/
|
||||
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
|
||||
inline val ATLEAST_API26_O get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
inline val ATMOST_API26_O get() = Build.VERSION.SDK_INT <= Build.VERSION_CODES.O
|
||||
|
||||
/** Android 8.1 **/
|
||||
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O_MR1)
|
||||
inline val ATLEAST_API27_O_MR1 get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1
|
||||
inline val ATMOST_API27_O_MR1 get() = Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1
|
||||
|
||||
/** Android 9 **/
|
||||
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.P)
|
||||
inline val ATLEAST_API28_P get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||
inline val ATMOST_API28_P get() = Build.VERSION.SDK_INT <= Build.VERSION_CODES.P
|
||||
|
||||
/** Android 10 **/
|
||||
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q)
|
||||
inline val ATLEAST_API29_Q get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
inline val ATMOST_API29_Q get() = Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q
|
||||
|
||||
/** Android 11 **/
|
||||
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R)
|
||||
inline val ATLEAST_API30_R get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
inline val ATMOST_API30_R get() = Build.VERSION.SDK_INT <= Build.VERSION_CODES.R
|
||||
|
||||
/** Android 12 **/
|
||||
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
|
||||
inline val ATLEAST_API31_S get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
inline val ATMOST_API31_S get() = Build.VERSION.SDK_INT <= Build.VERSION_CODES.S
|
||||
|
||||
/** Android 12L **/
|
||||
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S_V2)
|
||||
inline val ATLEAST_API32_S_V2 get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2
|
||||
inline val ATMOST_API32_S_V2 get() = Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2
|
||||
|
||||
/** Android 13 **/
|
||||
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU)
|
||||
inline val ATLEAST_API33_T get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
inline val ATMOST_API33_T get() = Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU
|
||||
}
|
||||
@@ -18,7 +18,6 @@ package dev.patrickgold.florisboard.lib.devtools
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import dev.patrickgold.florisboard.appContext
|
||||
import dev.patrickgold.florisboard.lib.devtools.Flog.OUTPUT_CONSOLE
|
||||
import dev.patrickgold.florisboard.lib.devtools.Flog.createTag
|
||||
import dev.patrickgold.florisboard.lib.devtools.Flog.getStacktraceElement
|
||||
@@ -229,7 +228,7 @@ object Flog {
|
||||
flogLevels: FlogLevel,
|
||||
flogOutputs: FlogOutput
|
||||
) {
|
||||
this.applicationContext = WeakReference(context.appContext().value)
|
||||
this.applicationContext = WeakReference(context.applicationContext)
|
||||
this.isFloggingEnabled = isFloggingEnabled
|
||||
this.flogTopics = flogTopics
|
||||
this.flogLevels = flogLevels
|
||||
72
plugin/build.gradle.kts
Normal file
72
plugin/build.gradle.kts
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Suppress needed until https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
|
||||
@file:Suppress("DSL_SCOPE_VIOLATION")
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.agp.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
val projectCompileSdk: String by project
|
||||
val projectMinSdk: String by project
|
||||
val projectVersionName: String by project
|
||||
val projectVersionNameSuffix: String by project
|
||||
|
||||
android {
|
||||
namespace = "dev.patrickgold.florisboard.plugin"
|
||||
compileSdk = projectCompileSdk.toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = projectMinSdk.toInt()
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
maybeCreate("main").apply {
|
||||
java.srcDir("src/main/kotlin")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":ime-lib"))
|
||||
implementation(libs.androidx.annotation)
|
||||
implementation(libs.kotlinx.coroutines)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
/*val sourcesJar = tasks.register<Jar>("sourcesJar") {
|
||||
archiveClassifier.set("sources")
|
||||
from(android.sourceSets.getByName("main").java.srcDirs)
|
||||
}
|
||||
|
||||
mavenPublishing {
|
||||
coordinates(projectGroupId, artifactId, projectVersion)
|
||||
}*/
|
||||
17
plugin/src/main/AndroidManifest.xml
Normal file
17
plugin/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2023 Patrick Goldinger
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
<manifest />
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.plugin
|
||||
|
||||
import android.os.Message
|
||||
import android.os.Messenger
|
||||
import android.os.Parcelable
|
||||
|
||||
/**
|
||||
* Class which wraps a [Message] object and also helps correctly encoding and decoding the message's what and data
|
||||
* fields.
|
||||
*
|
||||
* Layout of the binary what flags:
|
||||
* | Byte 3 | Byte 2 | Byte 1 | Byte 0 |
|
||||
* |--------|--------|--------|--------|
|
||||
* |0 | | | 1111| Source of the message, may either be [SOURCE_SERVICE] or [SOURCE_CONSUMER].
|
||||
* |0 | | |1111 | Type of the message, may either be [TYPE_REQUEST] or [TYPE_RESPONSE].
|
||||
* |0 | |11111111| | Message action, may be one of the ACTION_* constants.
|
||||
* |01111111|11111111| | | Reserved for future use.
|
||||
*/
|
||||
data class FlorisPluginMessage(
|
||||
val what: Int,
|
||||
val id: Int,
|
||||
val data: String?,
|
||||
val obj: Parcelable?,
|
||||
var replyTo: Messenger? = null,
|
||||
) {
|
||||
companion object {
|
||||
const val SOURCE_SERVICE = 1
|
||||
const val SOURCE_CONSUMER = 2
|
||||
|
||||
const val TYPE_REQUEST = 1
|
||||
const val TYPE_RESPONSE = 2
|
||||
|
||||
const val ACTION_EVALUATE_IS_SUPPORTED = 1
|
||||
const val ACTION_PRELOAD = 2
|
||||
const val ACTION_SPELL = 3
|
||||
const val ACTION_SUGGEST = 4
|
||||
|
||||
private const val M_SOURCE = 0x0000000F
|
||||
private val O_SOURCE = M_SOURCE.countTrailingZeroBits()
|
||||
private const val M_TYPE = 0x000000F0
|
||||
private val O_TYPE = M_TYPE.countTrailingZeroBits()
|
||||
private const val M_ACTION = 0x0000FF00
|
||||
private val O_ACTION = M_ACTION.countTrailingZeroBits()
|
||||
|
||||
private const val MESSAGE_DATA = "org.florisboard.plugin.MESSAGE_DATA"
|
||||
|
||||
private fun encodeWhatField(source: Int, type: Int, action: Int): Int {
|
||||
return ((source shl O_SOURCE) and M_SOURCE) or
|
||||
((type shl O_TYPE) and M_TYPE) or
|
||||
((action shl O_ACTION) and M_ACTION)
|
||||
}
|
||||
|
||||
private fun decodeWhatField(what: Int): Triple<Int, Int, Int> {
|
||||
return Triple(
|
||||
((what and M_SOURCE) shr O_SOURCE),
|
||||
((what and M_TYPE) shr O_TYPE),
|
||||
((what and M_ACTION) shr O_ACTION),
|
||||
)
|
||||
}
|
||||
|
||||
fun fromAndroidMessage(msg: Message): FlorisPluginMessage {
|
||||
return FlorisPluginMessage(
|
||||
what = msg.what,
|
||||
id = msg.arg1,
|
||||
data = msg.peekData()?.getString(MESSAGE_DATA),
|
||||
obj = msg.obj as? Parcelable,
|
||||
replyTo = msg.replyTo,
|
||||
)
|
||||
}
|
||||
|
||||
fun requestToService(
|
||||
action: Int,
|
||||
id: Int = 0,
|
||||
data: String? = null,
|
||||
obj: Parcelable? = null,
|
||||
): FlorisPluginMessage {
|
||||
val what = encodeWhatField(SOURCE_CONSUMER, TYPE_REQUEST, action)
|
||||
return FlorisPluginMessage(what, id, data, obj)
|
||||
}
|
||||
|
||||
fun replyToConsumer(
|
||||
action: Int,
|
||||
id: Int = 0,
|
||||
data: String? = null,
|
||||
obj: Parcelable? = null,
|
||||
): FlorisPluginMessage {
|
||||
val what = encodeWhatField(SOURCE_SERVICE, TYPE_RESPONSE, action)
|
||||
return FlorisPluginMessage(what, id, data, obj)
|
||||
}
|
||||
|
||||
fun requestToConsumer(
|
||||
action: Int,
|
||||
id: Int = 0,
|
||||
data: String? = null,
|
||||
obj: Parcelable? = null,
|
||||
): FlorisPluginMessage {
|
||||
val what = encodeWhatField(SOURCE_SERVICE, TYPE_REQUEST, action)
|
||||
return FlorisPluginMessage(what, id, data, obj)
|
||||
}
|
||||
|
||||
fun replyToService(
|
||||
action: Int,
|
||||
id: Int = 0,
|
||||
data: String? = null,
|
||||
obj: Parcelable? = null,
|
||||
): FlorisPluginMessage {
|
||||
val what = encodeWhatField(SOURCE_CONSUMER, TYPE_RESPONSE, action)
|
||||
return FlorisPluginMessage(what, id, data, obj)
|
||||
}
|
||||
}
|
||||
|
||||
fun metadata(): Triple<Int, Int, Int> {
|
||||
return decodeWhatField(what)
|
||||
}
|
||||
|
||||
fun toAndroidMessage(): Message {
|
||||
val msg = Message.obtain(null, what, id, 0, obj)
|
||||
if (data != null) {
|
||||
msg.data.putString(MESSAGE_DATA, data)
|
||||
}
|
||||
msg.replyTo = replyTo
|
||||
return msg
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.plugin
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.XmlRes
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
private const val FallbackValue = "(unspecified)"
|
||||
|
||||
data class FlorisPluginMetadata(
|
||||
val id: String = FallbackValue,
|
||||
val version: String = FallbackValue,
|
||||
val title: ValueOrRef<String> = ValueOrRef.value(FallbackValue),
|
||||
val shortDescription: ValueOrRef<String>? = null,
|
||||
val longDescription: ValueOrRef<String>? = null,
|
||||
val maintainers: ValueOrRef<List<String>>? = null,
|
||||
val homepage: ValueOrRef<String>? = null,
|
||||
val issueTracker: ValueOrRef<String>? = null,
|
||||
val privacyPolicy: ValueOrRef<String>? = null,
|
||||
val license: ValueOrRef<String>? = null,
|
||||
val settingsActivity: String? = null,
|
||||
var spellingConfig: FlorisPluginFeature.SpellingConfig? = null,
|
||||
var suggestionConfig: FlorisPluginFeature.SuggestionConfig? = null,
|
||||
) {
|
||||
fun features(): List<FlorisPluginFeature> {
|
||||
return buildList {
|
||||
spellingConfig?.let { add(it) }
|
||||
suggestionConfig?.let { add(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun toString(packageContext: Context): String {
|
||||
if (id.isBlank()) return "FlorisPluginMetadata { invalid }"
|
||||
return """
|
||||
FlorisPluginMetadata {
|
||||
id=$id
|
||||
version=$version
|
||||
title=${title.get(packageContext)}
|
||||
shortDescription=${shortDescription?.get(packageContext)}
|
||||
longDescription=${longDescription?.get(packageContext)}
|
||||
maintainers=${maintainers?.get(packageContext)}
|
||||
homepage=${homepage?.get(packageContext)}
|
||||
issueTracker=${issueTracker?.get(packageContext)}
|
||||
privacyPolicy=${privacyPolicy?.get(packageContext)}
|
||||
license=${license?.get(packageContext)}
|
||||
settingsActivity=$settingsActivity
|
||||
spellingConfig=$spellingConfig
|
||||
suggestionConfig=$suggestionConfig
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FL_NAMESPACE_URL = "https://schemas.florisboard.org/plugin"
|
||||
|
||||
fun parseFromXml(packageContext: Context, @XmlRes id: Int): FlorisPluginMetadata {
|
||||
val parser = packageContext.resources.getXml(id)
|
||||
parser.next()
|
||||
|
||||
fun attrOrNull(name: String): String? {
|
||||
return try {
|
||||
parser.getAttributeValue(FL_NAMESPACE_URL, name)?.takeIf { it.isNotBlank() }
|
||||
} catch (_: IndexOutOfBoundsException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun attr(name: String): String {
|
||||
return attrOrNull(name).takeIf { !it.isNullOrBlank() }
|
||||
?: throw IllegalStateException("Missing required attribute 'fl:$name'")
|
||||
}
|
||||
|
||||
var pluginMetadata: FlorisPluginMetadata? = null
|
||||
var eventType = parser.eventType
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
if (eventType != XmlPullParser.START_TAG) {
|
||||
eventType = parser.next()
|
||||
continue
|
||||
}
|
||||
when (parser.name) {
|
||||
"plugin" -> {
|
||||
pluginMetadata = FlorisPluginMetadata(
|
||||
id = attr("id"),
|
||||
version = attr("version"),
|
||||
title = strOrRefOf(attr("title"))!!,
|
||||
shortDescription = strOrRefOf(attrOrNull("shortDescription")),
|
||||
longDescription = strOrRefOf(attrOrNull("longDescription")),
|
||||
maintainers = strListOrRefOf(attrOrNull("maintainers")),
|
||||
homepage = strOrRefOf(attrOrNull("homepage")),
|
||||
issueTracker = strOrRefOf(attrOrNull("issueTracker")),
|
||||
privacyPolicy = strOrRefOf(attrOrNull("privacyPolicy")),
|
||||
license = strOrRefOf(attrOrNull("license")),
|
||||
settingsActivity = attrOrNull("settingsActivity"),
|
||||
)
|
||||
}
|
||||
"spelling" -> pluginMetadata?.spellingConfig = FlorisPluginFeature.SpellingConfig
|
||||
"suggestion" -> {
|
||||
pluginMetadata?.suggestionConfig = FlorisPluginFeature.SuggestionConfig(
|
||||
requireAlwaysEnabled = attrOrNull("requireAlwaysEnabled").toBoolean(),
|
||||
)
|
||||
}
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
|
||||
return pluginMetadata ?: throw IllegalStateException("Invalid XML structure")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface FlorisPluginFeature {
|
||||
object SpellingConfig : FlorisPluginFeature
|
||||
|
||||
data class SuggestionConfig(
|
||||
val requireAlwaysEnabled: Boolean,
|
||||
) : FlorisPluginFeature
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.plugin
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Message
|
||||
import android.os.Messenger
|
||||
import dev.patrickgold.florisboard.ime.core.ComputedSubtype
|
||||
import dev.patrickgold.florisboard.ime.nlp.NlpProvider
|
||||
import dev.patrickgold.florisboard.ime.nlp.SpellingProvider
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionProvider
|
||||
import dev.patrickgold.florisboard.ime.nlp.SuggestionRequest
|
||||
import dev.patrickgold.florisboard.lib.devtools.flogDebug
|
||||
import dev.patrickgold.florisboard.lib.devtools.flogError
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
abstract class FlorisPluginService : Service(), NlpProvider {
|
||||
companion object {
|
||||
const val SERVICE_INTERFACE = "org.florisboard.plugin.FlorisPluginService"
|
||||
const val SERVICE_METADATA = "org.florisboard.plugin.flp"
|
||||
|
||||
const val CONSUMER_PACKAGE_NAME = "org.florisboard.plugin.CONSUMER_PACKAGE_NAME"
|
||||
const val CONSUMER_VERSION_CODE = "org.florisboard.plugin.CONSUMER_VERSION_CODE"
|
||||
const val CONSUMER_VERSION_NAME = "org.florisboard.plugin.CONSUMER_VERSION_NAME"
|
||||
}
|
||||
|
||||
private lateinit var serviceMessenger: Messenger
|
||||
private lateinit var consumerInfo: PluginConsumerInfo
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
final override fun onCreate() {
|
||||
flogDebug { "" }
|
||||
|
||||
super.onCreate()
|
||||
runBlocking(scope.coroutineContext) {
|
||||
create()
|
||||
}
|
||||
}
|
||||
|
||||
final override fun onBind(intent: Intent?): IBinder? {
|
||||
flogDebug { "${intent?.toUri(0)}" }
|
||||
if (intent == null) return null
|
||||
|
||||
consumerInfo = PluginConsumerInfo(
|
||||
packageName = intent.getStringExtra(CONSUMER_PACKAGE_NAME) ?: return null,
|
||||
versionCode = intent.getIntExtra(CONSUMER_VERSION_CODE, -1).takeIf { it > 0 } ?: return null,
|
||||
versionName = intent.getStringExtra(CONSUMER_VERSION_NAME) ?: return null,
|
||||
)
|
||||
serviceMessenger = Messenger(IncomingHandler(this))
|
||||
|
||||
flogDebug { "consumerInfo = $consumerInfo" }
|
||||
return serviceMessenger.binder
|
||||
}
|
||||
|
||||
final override fun onDestroy() {
|
||||
flogDebug { "" }
|
||||
|
||||
runBlocking(scope.coroutineContext) {
|
||||
destroy()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
protected data class PluginConsumerInfo(
|
||||
val packageName: String,
|
||||
val versionCode: Int,
|
||||
val versionName: String,
|
||||
)
|
||||
|
||||
private class IncomingHandler(service: FlorisPluginService) : Handler(service.mainLooper) {
|
||||
private val serviceReference = WeakReference(service)
|
||||
|
||||
override fun handleMessage(msg: Message) {
|
||||
val service = serviceReference.get() ?: return
|
||||
val message = FlorisPluginMessage.fromAndroidMessage(msg)
|
||||
val (source, type, action) = message.metadata()
|
||||
if (source != FlorisPluginMessage.SOURCE_CONSUMER) {
|
||||
return
|
||||
}
|
||||
|
||||
when (type) {
|
||||
FlorisPluginMessage.TYPE_REQUEST -> when (action) {
|
||||
FlorisPluginMessage.ACTION_EVALUATE_IS_SUPPORTED -> processAction("EVALUATE_IS_SUPPORTED") {
|
||||
val data = message.data ?: error("Request message contains no data")
|
||||
val id = message.id
|
||||
val replyToMessenger = message.replyTo ?: error("Request message contains no replyTo field")
|
||||
val subtype = Json.decodeFromString<ComputedSubtype>(data)
|
||||
service.scope.launch {
|
||||
flogDebug { "ACTION_EVALUATE_IS_SUPPORTED: $subtype" }
|
||||
val info = service.evaluateIsSupported(subtype)
|
||||
val responseMessage = FlorisPluginMessage.replyToConsumer(
|
||||
action = FlorisPluginMessage.ACTION_EVALUATE_IS_SUPPORTED,
|
||||
id = id,
|
||||
data = Json.encodeToString(info),
|
||||
)
|
||||
replyToMessenger.send(responseMessage.toAndroidMessage())
|
||||
}
|
||||
}
|
||||
|
||||
FlorisPluginMessage.ACTION_PRELOAD -> processAction("PRELOAD") {
|
||||
val data = message.data ?: error("Request message contains no data")
|
||||
val subtype = Json.decodeFromString<ComputedSubtype>(data)
|
||||
service.scope.launch {
|
||||
flogDebug { "ACTION_PRELOAD: $subtype" }
|
||||
service.preload(subtype)
|
||||
}
|
||||
}
|
||||
|
||||
FlorisPluginMessage.ACTION_SPELL -> processAction("SPELL") {
|
||||
if (service !is SpellingProvider) {
|
||||
error("This action can only be executed by a SpellingProvider")
|
||||
}
|
||||
val data = message.data ?: error("Request message contains no data")
|
||||
val id = message.id
|
||||
val replyToMessenger = message.replyTo ?: error("Request message contains no replyTo field")
|
||||
val suggestionRequest = Json.decodeFromString<SuggestionRequest>(data)
|
||||
service.scope.launch {
|
||||
flogDebug { "ACTION_SPELL: $suggestionRequest" }
|
||||
val spellingResult = service.spell(
|
||||
suggestionRequest.subtypeId,
|
||||
suggestionRequest.word,
|
||||
suggestionRequest.prevWords,
|
||||
suggestionRequest.flags,
|
||||
)
|
||||
val responseMessage = FlorisPluginMessage.replyToConsumer(
|
||||
action = FlorisPluginMessage.ACTION_SPELL,
|
||||
id = id,
|
||||
obj = spellingResult.suggestionsInfo,
|
||||
)
|
||||
replyToMessenger.send(responseMessage.toAndroidMessage())
|
||||
}
|
||||
}
|
||||
|
||||
FlorisPluginMessage.ACTION_SUGGEST -> processAction("SUGGEST") {
|
||||
if (service !is SuggestionProvider) {
|
||||
error("This action can only be executed by a SuggestionProvider")
|
||||
}
|
||||
val data = message.data ?: error("Request message contains no data")
|
||||
val id = message.id
|
||||
val replyToMessenger = message.replyTo ?: error("Request message contains no replyTo field")
|
||||
val suggestionRequest = Json.decodeFromString<SuggestionRequest>(data)
|
||||
service.scope.launch {
|
||||
flogDebug { "ACTION_SUGGEST: $suggestionRequest" }
|
||||
val candidatesList = service.suggest(
|
||||
suggestionRequest.subtypeId,
|
||||
suggestionRequest.word,
|
||||
suggestionRequest.prevWords,
|
||||
suggestionRequest.flags,
|
||||
)
|
||||
val responseMessage = FlorisPluginMessage.replyToConsumer(
|
||||
action = FlorisPluginMessage.ACTION_SUGGEST,
|
||||
id = id,
|
||||
data = Json.encodeToString(candidatesList),
|
||||
)
|
||||
replyToMessenger.send(responseMessage.toAndroidMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun processAction(name: String, block: () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
} catch (e: SerializationException) {
|
||||
flogError { "ACTION_$name: Ill-formatted JSON data with error: $e" }
|
||||
return
|
||||
} catch (e: IllegalArgumentException) {
|
||||
flogError { "ACTION_$name: Invalid JSON data with error: $e" }
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
flogError { "ACTION_$name: Generic error: $e" }
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.plugin
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Resources
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
|
||||
sealed class ValueOrRef<V>(val refRetriever: (Resources, Int) -> V) {
|
||||
class Value<V>(val value: V, refRetriever: (Resources, Int) -> V) : ValueOrRef<V>(refRetriever)
|
||||
class Ref<V>(val id: Int, refRetriever: (Resources, Int) -> V) : ValueOrRef<V>(refRetriever)
|
||||
|
||||
fun get(packageContext: Context): V {
|
||||
return when (this) {
|
||||
is Value -> value
|
||||
is Ref -> refRetriever(packageContext.resources, id)
|
||||
}
|
||||
}
|
||||
|
||||
fun getOrNull(packageContext: Context): V? {
|
||||
return try {
|
||||
get(packageContext)
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
} catch (_: Resources.NotFoundException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return when (this) {
|
||||
is Value -> "ValueOrRef.Value { value=$value }"
|
||||
is Ref -> "ValueOrRef.Ref { id=$id }"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
inline fun <reified V> value(value: V) = Value(value, getRetriever())
|
||||
|
||||
inline fun <reified V> ref(id: Int) = Ref<V>(id, getRetriever())
|
||||
|
||||
inline fun <reified V> getRetriever(): (Resources, Int) -> V {
|
||||
return when (V::class) {
|
||||
String::class -> { resources, id -> resources.getString(id) as V }
|
||||
List::class -> { resources, id -> resources.getStringArray(id).toList() as V }
|
||||
else -> throw IllegalArgumentException("Unsupported type: ${V::class}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
fun strOrRefOf(rawValue: String?): ValueOrRef<String>? {
|
||||
contract {
|
||||
returnsNotNull() implies (rawValue != null)
|
||||
}
|
||||
|
||||
if (rawValue == null) return null
|
||||
return if (rawValue.startsWith("@")) {
|
||||
val id = rawValue.substring(1).toInt()
|
||||
ValueOrRef.ref(id)
|
||||
} else {
|
||||
ValueOrRef.value(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
fun strListOrRefOf(rawValue: String?): ValueOrRef<List<String>>? {
|
||||
contract {
|
||||
returnsNotNull() implies (rawValue != null)
|
||||
}
|
||||
|
||||
if (rawValue == null) return null
|
||||
return if (rawValue.startsWith("@")) {
|
||||
val id = rawValue.substring(1).toInt()
|
||||
ValueOrRef.ref(id)
|
||||
} else {
|
||||
ValueOrRef.value(rawValue.split(";").map { it.trim() }.filter { it.isNotBlank() })
|
||||
}
|
||||
}
|
||||
@@ -35,3 +35,5 @@ dependencyResolutionManagement {
|
||||
|
||||
include(":app")
|
||||
include(":benchmark")
|
||||
include(":ime-lib")
|
||||
include(":plugin")
|
||||
|
||||
Reference in New Issue
Block a user