Compare commits

...

39 Commits

Author SHA1 Message Date
Patrick Goldinger
99902681dc Add .cxx directories to clean command 2023-12-09 03:57:35 +01:00
Patrick Goldinger
eeaef4f5de Fix ICU build for Android unnecessarily building for host for every arch
This should improve compile performance for workflow runners.
2023-12-09 03:57:35 +01:00
Patrick Goldinger
e70a84bea7 Add new SubtypeSupportInfo API to NlpProvider
This new API allows the UI to dynamically check if a subtype is supported by a given NLP Provider.
2023-12-09 03:57:35 +01:00
Patrick Goldinger
857e315e6c Adjust NLP dictionary language matching 2023-12-09 03:57:34 +01:00
Patrick Goldinger
04094985a7 Remove unused core assets 2023-12-09 03:57:34 +01:00
Patrick Goldinger
d352f9bb11 Fix naming in jni_exception utils 2023-12-09 03:57:34 +01:00
Patrick Goldinger
a961defc12 Adjust Gradle task order to ensure assets get packed after Kotlin and native compilation 2023-12-09 03:57:34 +01:00
Patrick Goldinger
f175ffe7ca Add support for plugins requiring always suggestion enabled 2023-12-09 03:57:33 +01:00
Patrick Goldinger
031d9fb75b Implement word prediction support using the new NLP core
The IPC communication causes significant lag atm.
2023-12-09 03:57:31 +01:00
Patrick Goldinger
eb23dc8ba1 Implement spell checking support using the new NLP core 2023-12-09 03:56:39 +01:00
Patrick Goldinger
a21114ad03 Rework FlorisPluginMessage, NlpProviders interface & message handling 2023-12-09 03:56:39 +01:00
Patrick Goldinger
65bf358c75 Add JNI utils for transporting lists over interface 2023-12-09 03:56:38 +01:00
Patrick Goldinger
b9d6300fa5 Implement basic dictionary locale matching mechanism 2023-12-09 03:56:38 +01:00
Patrick Goldinger
be68cb07ac Fix third-party plugins being indexed even though they are unsupported 2023-12-09 03:56:38 +01:00
Patrick Goldinger
9d23925a60 Allow FlorisBoard UIRs to be used for internal plugins 2023-12-09 03:56:38 +01:00
Patrick Goldinger
25c3229984 Add UI for unrecognized plugins in Language & Layouts screen 2023-12-09 03:56:37 +01:00
Patrick Goldinger
d7defdce18 Fix plugin metadata not checking for blank strings 2023-12-09 03:56:37 +01:00
Patrick Goldinger
842d29be04 Completely refurbish Language & Layouts screen
Additionally add PluginViewScreen
2023-12-09 03:56:37 +01:00
Patrick Goldinger
d3ab9effb5 Improve and rework plugin metadata
Plugins can now specify a settings activity and a short/long description
2023-12-09 03:56:36 +01:00
Patrick Goldinger
c2da93add5 Implement proper connection management for plugins 2023-12-09 03:56:36 +01:00
Patrick Goldinger
de7dc5361a Add base implementation for plugin IPC 2023-12-09 03:56:36 +01:00
Patrick Goldinger
90cb84fda0 Rename ime-model to ime-lib 2023-12-09 03:56:36 +01:00
Patrick Goldinger
e70c37e022 Move Flog utils to ime-model 2023-12-09 03:56:35 +01:00
Patrick Goldinger
d0f39d18ec Move AndroidVersion to ime-model & Add AndroidIntent utils 2023-12-09 03:56:35 +01:00
Patrick Goldinger
c24a3c5df3 Adjust core app code base to changes in IME model and plugin system 2023-12-09 03:56:35 +01:00
Patrick Goldinger
7e482ef9ee Add template LatinLanguageProviderService 2023-12-09 03:56:34 +01:00
Patrick Goldinger
d604d78109 Add simple plugin indexer class 2023-12-09 03:56:34 +01:00
Patrick Goldinger
e47e0c537f Add plugin metadata / Specify FlorisPluginService IDs 2023-12-09 03:56:34 +01:00
Patrick Goldinger
3bf8264d0b Add IME model module
This module contains all data objects that are both relevant for the plugin module and the core app module
2023-12-09 03:56:33 +01:00
Patrick Goldinger
dded3dddc9 Fix gradle.properties incorrectly using quotation marks 2023-12-09 03:56:33 +01:00
Patrick Goldinger
252cbcc4f9 Setup plugin library build system and manifest 2023-12-09 03:56:33 +01:00
Patrick Goldinger
a22c82baf3 Move project meta data to gradle.properties 2023-12-09 03:56:32 +01:00
Patrick Goldinger
9150ec8e36 Continue setting up LatinLanguageProvider and rework some related features 2023-12-09 03:56:32 +01:00
Patrick Goldinger
d47900bd93 Add new NLP provider registry 2023-12-09 03:56:32 +01:00
Patrick Goldinger
07373ed5b6 Add basic LatinNlpSession and provide code fixes 2023-12-09 03:56:19 +01:00
Patrick Goldinger
eb9def1bce Add native package, NativeException and <jni_exception>
These utils provide a safe way for native C++ code to throw exception and to be properly forwarded to the JVM via the JNI interface. Most of the C++ STL exceptions have their specific exception class counterpart in Kotlin, for all other C++ exceptions the std::exception base catch statement is used.
2023-12-09 03:56:19 +01:00
Patrick Goldinger
f43e8d5ea8 Soft-link C++ format rules to nlp submodule 2023-12-09 03:56:18 +01:00
Patrick Goldinger
d2299f0b7d Rework C++ JNI Utils 2023-12-09 03:56:18 +01:00
Patrick Goldinger
7ca1276af5 Add experimental dictionary manager screen 2023-12-09 03:56:14 +01:00
89 changed files with 3628 additions and 1006 deletions

2
app/.gitignore vendored
View File

@@ -1,2 +1,2 @@
# Exclude auto-generated icu4c assets
src/main/assets/icu4c/
.cxx_icu4c

View File

@@ -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)

View File

@@ -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

View File

@@ -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"}
}
}

View File

@@ -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"}
}
}

View File

@@ -0,0 +1 @@
nlp/.clang-format

View File

@@ -0,0 +1 @@
nlp/.clang-tidy

View File

@@ -0,0 +1 @@
nlp/.editorconfig

View File

@@ -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)

View File

@@ -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;
});
}

View 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();
});
}

View 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);
});
}

View File

@@ -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)

View 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"

View 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_

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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())
}
}
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
)
}
}
}
}

View File

@@ -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,

View File

@@ -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,
)
}
}
}

View File

@@ -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(

View File

@@ -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"
}
}

View File

@@ -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)
)
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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,
)

View File

@@ -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)
}
}

View File

@@ -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}"
}
}

View File

@@ -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,
)
}

View File

@@ -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))
}
}
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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>>,
)

View File

@@ -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,
)
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}
}*/

View File

@@ -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).
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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,
)

View File

@@ -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),

View File

@@ -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))
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)")
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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())
}
/**

View File

@@ -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)

View File

@@ -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 }
}
}
}

View File

@@ -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)

View File

@@ -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 &amp; 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) -->

View 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 &lt;patrick@patrickgold.dev&gt;"
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>

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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)
}*/

View 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 />

View File

@@ -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
}
}

View File

@@ -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) {
/**

View File

@@ -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
}

View File

@@ -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
/**

View File

@@ -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;
}

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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())
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
View 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)
}*/

View 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 />

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}
}
}
}

View File

@@ -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() })
}
}

View File

@@ -35,3 +35,5 @@ dependencyResolutionManagement {
include(":app")
include(":benchmark")
include(":ime-lib")
include(":plugin")