mirror of
https://github.com/fcitx5-android/fcitx5-android.git
synced 2026-08-02 04:44:35 +08:00
cloud pinyin plugin
This commit is contained in:
parent
bcb694384d
commit
7d029911cc
@ -17,7 +17,9 @@ import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.fcitx.fcitx5.android.common.ipc.IClipboardEntryTransformer
|
||||
import org.fcitx.fcitx5.android.common.ipc.ICloudPinyinProvider
|
||||
import org.fcitx.fcitx5.android.common.ipc.IFcitxRemoteService
|
||||
import org.fcitx.fcitx5.android.core.CloudPinyinIpc
|
||||
import org.fcitx.fcitx5.android.core.data.DataManager
|
||||
import org.fcitx.fcitx5.android.core.reloadPinyinDict
|
||||
import org.fcitx.fcitx5.android.core.reloadQuickPhrase
|
||||
@ -36,6 +38,7 @@ class FcitxRemoteService : Service() {
|
||||
private val scope = MainScope() + CoroutineName("FcitxRemoteService")
|
||||
|
||||
private val clipboardTransformers = CopyOnWriteArrayList<IClipboardEntryTransformer>()
|
||||
private var cloudPinyinProvider: ICloudPinyinProvider? = null
|
||||
|
||||
private fun transformClipboard(source: String): String {
|
||||
var result = source
|
||||
@ -100,6 +103,32 @@ class FcitxRemoteService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun registerCloudPinyinProvider(provider: ICloudPinyinProvider) {
|
||||
synchronized(this@FcitxRemoteService) {
|
||||
if (cloudPinyinProvider != null) {
|
||||
Timber.w("Cloud pinyin provider has already been registered")
|
||||
return
|
||||
}
|
||||
cloudPinyinProvider = provider
|
||||
provider.asBinder().linkToDeath({
|
||||
unregisterCloudPinyinProvider(provider)
|
||||
}, 0)
|
||||
CloudPinyinIpc.register(provider)
|
||||
}
|
||||
Timber.d("Cloud pinyin provider registered")
|
||||
}
|
||||
|
||||
override fun unregisterCloudPinyinProvider(provider: ICloudPinyinProvider) {
|
||||
synchronized(this@FcitxRemoteService) {
|
||||
if (cloudPinyinProvider?.asBinder() != provider.asBinder()) {
|
||||
return
|
||||
}
|
||||
cloudPinyinProvider = null
|
||||
CloudPinyinIpc.unregister(provider)
|
||||
}
|
||||
Timber.d("Cloud pinyin provider unregistered")
|
||||
}
|
||||
|
||||
override fun reloadPinyinDict() {
|
||||
FcitxDaemon.getFirstConnectionOrNull()?.runIfReady { reloadPinyinDict() }
|
||||
}
|
||||
@ -128,6 +157,8 @@ class FcitxRemoteService : Service() {
|
||||
Timber.d("FcitxRemoteService onDestroy")
|
||||
scope.cancel()
|
||||
clipboardTransformers.clear()
|
||||
cloudPinyinProvider?.let { CloudPinyinIpc.unregister(it) }
|
||||
cloudPinyinProvider = null
|
||||
runBlocking { updateClipboardManager() }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
|
||||
*/
|
||||
package org.fcitx.fcitx5.android.core
|
||||
|
||||
import android.os.RemoteException
|
||||
import androidx.annotation.Keep
|
||||
import org.fcitx.fcitx5.android.common.ipc.ICloudPinyinCallback
|
||||
import org.fcitx.fcitx5.android.common.ipc.ICloudPinyinProvider
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* The native addon lives in a plugin APK, while Binder endpoints must be owned
|
||||
* by the main process. This object is their narrow, request-only bridge.
|
||||
*/
|
||||
@Keep
|
||||
object CloudPinyinIpc {
|
||||
|
||||
@Volatile
|
||||
private var provider: ICloudPinyinProvider? = null
|
||||
|
||||
fun register(provider: ICloudPinyinProvider) {
|
||||
this.provider = provider
|
||||
}
|
||||
|
||||
fun unregister(provider: ICloudPinyinProvider) {
|
||||
if (this.provider?.asBinder() == provider.asBinder()) {
|
||||
this.provider = null
|
||||
nativeOnProviderUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadNativeLibrary(nativeLibraryDir: String) {
|
||||
System.load("$nativeLibraryDir/libcloudpinyin.so")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun request(requestId: Long, pinyin: String, backend: String, proxy: String): Boolean {
|
||||
val currentProvider = provider ?: return false
|
||||
return try {
|
||||
currentProvider.request(requestId, pinyin, backend, proxy, callback)
|
||||
true
|
||||
} catch (e: RemoteException) {
|
||||
Timber.w(e, "Cloud pinyin provider request failed")
|
||||
if (provider?.asBinder() == currentProvider.asBinder()) {
|
||||
provider = null
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private val callback = object : ICloudPinyinCallback.Stub() {
|
||||
override fun onResponse(
|
||||
requestId: Long,
|
||||
httpStatus: Int,
|
||||
response: ByteArray?,
|
||||
error: String?
|
||||
) {
|
||||
nativeOnResponse(requestId, httpStatus, response ?: ByteArray(0), error ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeOnResponse(
|
||||
requestId: Long,
|
||||
httpStatus: Int,
|
||||
response: ByteArray,
|
||||
error: String
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeOnProviderUnavailable()
|
||||
}
|
||||
@ -437,6 +437,9 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner {
|
||||
extDomains.add(d)
|
||||
}
|
||||
}
|
||||
plugins.firstOrNull { it.name == "cloud_pinyin" }?.let {
|
||||
CloudPinyinIpc.loadNativeLibrary(it.nativeLibraryDir)
|
||||
}
|
||||
Timber.d(
|
||||
"""
|
||||
Starting fcitx with:
|
||||
|
||||
@ -214,6 +214,7 @@ object PreferenceScreenFactory {
|
||||
)
|
||||
ConfigExternal.ETy.QuickPhrase -> quickPhraseEditor()
|
||||
ConfigExternal.ETy.Chttrans -> addonConfigPreference("chttrans")
|
||||
ConfigExternal.ETy.CloudPinyin -> addonConfigPreference("cloudpinyin")
|
||||
ConfigExternal.ETy.TableGlobal -> addonConfigPreference("table")
|
||||
ConfigExternal.ETy.AndroidTable -> tableInputMethod()
|
||||
ConfigExternal.ETy.PinyinCustomPhrase -> pinyinCustomPhrase()
|
||||
|
||||
@ -181,6 +181,7 @@ sealed class ConfigDescriptor<T, U> : Parcelable {
|
||||
Punctuation,
|
||||
QuickPhrase,
|
||||
Chttrans,
|
||||
CloudPinyin,
|
||||
TableGlobal,
|
||||
PinyinCustomPhrase,
|
||||
RimeUserDataDir,
|
||||
@ -322,6 +323,7 @@ sealed class ConfigDescriptor<T, U> : Parcelable {
|
||||
"Punctuation" -> ConfigExternal.ETy.Punctuation
|
||||
"QuickPhrase", "Editor" -> ConfigExternal.ETy.QuickPhrase
|
||||
"Chttrans" -> ConfigExternal.ETy.Chttrans
|
||||
"CloudPinyin" -> ConfigExternal.ETy.CloudPinyin
|
||||
"TableGlobal" -> ConfigExternal.ETy.TableGlobal
|
||||
"CustomPhrase" -> ConfigExternal.ETy.PinyinCustomPhrase
|
||||
"UserDataDir" -> ConfigExternal.ETy.RimeUserDataDir
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
package org.fcitx.fcitx5.android.common.ipc;
|
||||
|
||||
interface ICloudPinyinCallback {
|
||||
void onResponse(long requestId, int httpStatus, in byte[] response, String error);
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package org.fcitx.fcitx5.android.common.ipc;
|
||||
|
||||
import org.fcitx.fcitx5.android.common.ipc.ICloudPinyinCallback;
|
||||
|
||||
interface ICloudPinyinProvider {
|
||||
void request(long requestId, String pinyin, String backend, String proxy,
|
||||
ICloudPinyinCallback callback);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package org.fcitx.fcitx5.android.common.ipc;
|
||||
|
||||
import org.fcitx.fcitx5.android.common.ipc.IClipboardEntryTransformer;
|
||||
import org.fcitx.fcitx5.android.common.ipc.ICloudPinyinProvider;
|
||||
|
||||
interface IFcitxRemoteService {
|
||||
/** Get the version name of fcitx app */
|
||||
@ -18,6 +19,11 @@ interface IFcitxRemoteService {
|
||||
/** Unregister a clipboard transformer to fcitx app */
|
||||
void unregisterClipboardEntryTransformer(IClipboardEntryTransformer transformer);
|
||||
|
||||
/** Register the sole cloud pinyin network provider. */
|
||||
void registerCloudPinyinProvider(ICloudPinyinProvider provider);
|
||||
/** Unregister the cloud pinyin network provider. */
|
||||
void unregisterCloudPinyinProvider(ICloudPinyinProvider provider);
|
||||
|
||||
/** Reload fcitx pinyin dictionary */
|
||||
void reloadPinyinDict();
|
||||
/** Reload fcitx quick phrase */
|
||||
|
||||
54
plugin/cloud-pinyin/build.gradle.kts
Normal file
54
plugin/cloud-pinyin/build.gradle.kts
Normal file
@ -0,0 +1,54 @@
|
||||
plugins {
|
||||
id("org.fcitx.fcitx5.android.app-convention")
|
||||
id("org.fcitx.fcitx5.android.plugin-app-convention")
|
||||
id("org.fcitx.fcitx5.android.native-app-convention")
|
||||
id("org.fcitx.fcitx5.android.build-metadata")
|
||||
id("org.fcitx.fcitx5.android.data-descriptor")
|
||||
id("org.fcitx.fcitx5.android.fcitx-component")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.fcitx.fcitx5.android.plugin.cloud_pinyin"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "org.fcitx.fcitx5.android.plugin.cloud_pinyin"
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
targets(
|
||||
"cloudpinyin"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
resValues = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
resValue("string", "app_name", "@string/app_name_release")
|
||||
proguardFile("proguard-rules.pro")
|
||||
}
|
||||
debug {
|
||||
resValue("string", "app_name", "@string/app_name_debug")
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
excludes += setOf(
|
||||
"**/libc++_shared.so",
|
||||
"**/libFcitx5*"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:fcitx5"))
|
||||
implementation(project(":lib:plugin-base"))
|
||||
implementation(libs.kotlinx.coroutines)
|
||||
}
|
||||
8
plugin/cloud-pinyin/proguard-rules.pro
vendored
Normal file
8
plugin/cloud-pinyin/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# disable obfuscation
|
||||
-dontobfuscate
|
||||
|
||||
# preserve the line number information for debugging stack traces
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# remove kotlin null checks
|
||||
-processkotlinnullchecks remove
|
||||
22
plugin/cloud-pinyin/src/main/AndroidManifest.xml
Normal file
22
plugin/cloud-pinyin/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="${mainApplicationId}.permission.IPC" />
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher_plugin_generic"
|
||||
android:label="@string/app_name">
|
||||
<service
|
||||
android:name=".MainService"
|
||||
android:directBootAware="true"
|
||||
android:exported="true"
|
||||
android:permission="${mainApplicationId}.permission.PLUGIN"
|
||||
tools:targetApi="24">
|
||||
<intent-filter>
|
||||
<action android:name="${mainApplicationId}.plugin.SERVICE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
15
plugin/cloud-pinyin/src/main/cpp/CMakeLists.txt
Normal file
15
plugin/cloud-pinyin/src/main/cpp/CMakeLists.txt
Normal file
@ -0,0 +1,15 @@
|
||||
cmake_minimum_required(VERSION 3.18)
|
||||
|
||||
project(fcitx5-android-plugin-cloud-pinyin VERSION ${VERSION_NAME})
|
||||
|
||||
# For reproducible build
|
||||
add_link_options("LINKER:--hash-style=gnu,--build-id=none")
|
||||
|
||||
# prefab dependency
|
||||
find_package(fcitx5 REQUIRED CONFIG)
|
||||
get_target_property(FCITX5_CMAKE_MODULES fcitx5::cmake INTERFACE_INCLUDE_DIRECTORIES)
|
||||
set(CMAKE_MODULE_PATH ${FCITX5_CMAKE_MODULES} ${CMAKE_MODULE_PATH})
|
||||
|
||||
find_package(Fcitx5Core MODULE)
|
||||
|
||||
add_subdirectory(cloud-pinyin)
|
||||
21
plugin/cloud-pinyin/src/main/cpp/cloud-pinyin/CMakeLists.txt
Normal file
21
plugin/cloud-pinyin/src/main/cpp/cloud-pinyin/CMakeLists.txt
Normal file
@ -0,0 +1,21 @@
|
||||
get_filename_component(FCITX5_ANDROID_ROOT
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../../../../../.." ABSOLUTE)
|
||||
set(FCITX5_CHINESE_ADDONS_SOURCE_DIR
|
||||
"${FCITX5_ANDROID_ROOT}/lib/fcitx5-chinese-addons/src/main/cpp/fcitx5-chinese-addons")
|
||||
|
||||
add_fcitx5_addon(cloudpinyin cloudpinyin.cpp)
|
||||
target_compile_definitions(cloudpinyin PRIVATE -DFCITX_GETTEXT_DOMAIN=\"fcitx5-chinese-addons\")
|
||||
target_link_libraries(cloudpinyin Fcitx5::Core Fcitx5::Config android log)
|
||||
target_compile_features(cloudpinyin PRIVATE cxx_std_20)
|
||||
target_include_directories(
|
||||
cloudpinyin PRIVATE
|
||||
"${FCITX5_CHINESE_ADDONS_SOURCE_DIR}/modules/cloudpinyin")
|
||||
install(TARGETS cloudpinyin DESTINATION "${CMAKE_INSTALL_LIBDIR}/fcitx5")
|
||||
|
||||
configure_file(cloudpinyin.conf.in.in cloudpinyin.conf.in)
|
||||
fcitx5_translate_desktop_file(
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/cloudpinyin.conf.in" cloudpinyin.conf
|
||||
PO_DIRECTORY
|
||||
"${FCITX5_CHINESE_ADDONS_SOURCE_DIR}/po")
|
||||
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/cloudpinyin.conf"
|
||||
DESTINATION "${FCITX_INSTALL_PKGDATADIR}/addon" COMPONENT config)
|
||||
@ -0,0 +1,11 @@
|
||||
[Addon]
|
||||
Name=Cloud Pinyin
|
||||
Category=Module
|
||||
Version=@PROJECT_VERSION@
|
||||
Library=libcloudpinyin
|
||||
Type=@FCITX_ADDON_TYPE@
|
||||
OnDemand=True
|
||||
Configurable=True
|
||||
|
||||
[Addon/Dependencies]
|
||||
0=core:@REQUIRED_FCITX_VERSION@
|
||||
382
plugin/cloud-pinyin/src/main/cpp/cloud-pinyin/cloudpinyin.cpp
Normal file
382
plugin/cloud-pinyin/src/main/cpp/cloud-pinyin/cloudpinyin.cpp
Normal file
@ -0,0 +1,382 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
* SPDX-FileCopyrightText: Copyright 2017-2017 CSSlayer <wengxt@gmail.com>
|
||||
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
|
||||
* SPDX-FileComment: Derived from:
|
||||
* - https://github.com/fcitx/fcitx5-chinese-addons/blob/5.1.13/modules/cloudpinyin/cloudpinyin.h
|
||||
* - https://github.com/fcitx/fcitx5-chinese-addons/blob/5.1.13/modules/cloudpinyin/cloudpinyin.cpp
|
||||
*/
|
||||
#include "cloudpinyin_public.h"
|
||||
|
||||
#include <jni.h>
|
||||
#include <fcitx-config/configuration.h>
|
||||
#include <fcitx-config/enum.h>
|
||||
#include <fcitx-config/iniparser.h>
|
||||
#include <fcitx-utils/eventdispatcher.h>
|
||||
#include <fcitx-utils/event.h>
|
||||
#include <fcitx-utils/eventloopinterface.h>
|
||||
#include <fcitx-utils/fs.h>
|
||||
#include <fcitx-utils/i18n.h>
|
||||
#include <fcitx-utils/log.h>
|
||||
#include <fcitx-utils/misc.h>
|
||||
#include <fcitx-utils/trackableobject.h>
|
||||
#include <fcitx/addonfactory.h>
|
||||
#include <fcitx/addoninstance.h>
|
||||
#include <fcitx/addonmanager.h>
|
||||
#include <fcitx/instance.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <list>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
class CloudPinyin;
|
||||
std::mutex instanceMutex;
|
||||
CloudPinyin *instance = nullptr;
|
||||
|
||||
FCITX_CONFIG_ENUM(CloudPinyinBackend, Google, GoogleCN, Baidu);
|
||||
FCITX_CONFIGURATION(
|
||||
CloudPinyinConfig,
|
||||
fcitx::Option<fcitx::KeyList> toggleKey{
|
||||
this, "Toggle Key", _("Toggle Key"), {fcitx::Key("Control+Alt+Shift+C")}};
|
||||
fcitx::Option<int> minimumLength{
|
||||
this, "MinimumPinyinLength", _("Minimum Pinyin Length"), 4};
|
||||
fcitx::Option<CloudPinyinBackend> backend{
|
||||
this, "Backend", _("Backend"), CloudPinyinBackend::GoogleCN};
|
||||
fcitx::OptionWithAnnotation<std::string, fcitx::ToolTipAnnotation> proxy{
|
||||
this,
|
||||
"Proxy",
|
||||
_("Proxy"),
|
||||
"",
|
||||
{},
|
||||
{},
|
||||
{_("The proxy format is [scheme]://[host]:[port], for example "
|
||||
"http://localhost:1080.")}};);
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int MAX_ERROR = 10;
|
||||
constexpr uint64_t MINUTE_IN_US = 60000000;
|
||||
constexpr char BRIDGE_CLASS[] =
|
||||
"org/fcitx/fcitx5/android/core/CloudPinyinIpc";
|
||||
|
||||
JavaVM *javaVm = nullptr;
|
||||
jclass bridgeClass = nullptr;
|
||||
jmethodID requestMethod = nullptr;
|
||||
|
||||
std::string backendName(CloudPinyinBackend backend) {
|
||||
switch (backend) {
|
||||
case CloudPinyinBackend::Google:
|
||||
return "Google";
|
||||
case CloudPinyinBackend::GoogleCN:
|
||||
return "GoogleCN";
|
||||
case CloudPinyinBackend::Baidu:
|
||||
return "Baidu";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string parseResult(CloudPinyinBackend backend,
|
||||
const std::vector<char> &response) {
|
||||
const std::string_view result(response.data(), response.size());
|
||||
std::string_view startMarker;
|
||||
std::string_view endMarker;
|
||||
if (backend == CloudPinyinBackend::Baidu) {
|
||||
startMarker = "[[\"";
|
||||
endMarker = "\",";
|
||||
} else {
|
||||
startMarker = "\",[\"";
|
||||
endMarker = "\"";
|
||||
}
|
||||
const auto marker = result.find(startMarker);
|
||||
if (marker == std::string_view::npos) {
|
||||
return {};
|
||||
}
|
||||
const auto start = marker + startMarker.size();
|
||||
const auto end = result.find(endMarker, start);
|
||||
return end == std::string_view::npos || end <= start
|
||||
? std::string()
|
||||
: std::string(result.substr(start, end - start));
|
||||
}
|
||||
|
||||
JNIEnv *getEnv(bool *attached) {
|
||||
*attached = false;
|
||||
if (!javaVm) {
|
||||
return nullptr;
|
||||
}
|
||||
JNIEnv *env = nullptr;
|
||||
if (javaVm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) ==
|
||||
JNI_EDETACHED) {
|
||||
if (javaVm->AttachCurrentThread(&env, nullptr) != JNI_OK) {
|
||||
return nullptr;
|
||||
}
|
||||
*attached = true;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
template <typename Key, typename Value> class LRUCache {
|
||||
public:
|
||||
explicit LRUCache(size_t capacity) : capacity_(capacity) {}
|
||||
|
||||
Value *find(const Key &key) {
|
||||
auto iter = entries_.find(key);
|
||||
if (iter == entries_.end()) {
|
||||
return nullptr;
|
||||
}
|
||||
order_.splice(order_.begin(), order_, iter->second.second);
|
||||
return &iter->second.first;
|
||||
}
|
||||
|
||||
void insert(const Key &key, Value value) {
|
||||
auto iter = entries_.find(key);
|
||||
if (iter != entries_.end()) {
|
||||
iter->second.first = std::move(value);
|
||||
order_.splice(order_.begin(), order_, iter->second.second);
|
||||
return;
|
||||
}
|
||||
if (entries_.size() == capacity_) {
|
||||
entries_.erase(order_.back());
|
||||
order_.pop_back();
|
||||
}
|
||||
order_.push_front(key);
|
||||
entries_.emplace(key, std::make_pair(std::move(value), order_.begin()));
|
||||
}
|
||||
|
||||
private:
|
||||
size_t capacity_;
|
||||
std::list<Key> order_;
|
||||
std::unordered_map<Key, std::pair<Value, typename std::list<Key>::iterator>>
|
||||
entries_;
|
||||
};
|
||||
|
||||
class CloudPinyin : public fcitx::AddonInstance,
|
||||
public fcitx::TrackableObject<CloudPinyin> {
|
||||
public:
|
||||
explicit CloudPinyin(fcitx::AddonManager *manager)
|
||||
: eventLoop_(manager->eventLoop()),
|
||||
dispatcher_(manager->instance()->eventDispatcher()) {
|
||||
resetError_ = eventLoop_->addTimeEvent(
|
||||
CLOCK_MONOTONIC, fcitx::now(CLOCK_MONOTONIC), MINUTE_IN_US,
|
||||
[this](fcitx::EventSourceTime *, uint64_t) {
|
||||
resetError();
|
||||
return true;
|
||||
});
|
||||
resetError_->setEnabled(false);
|
||||
std::lock_guard<std::mutex> lock(instanceMutex);
|
||||
instance = this;
|
||||
reloadConfig();
|
||||
}
|
||||
|
||||
~CloudPinyin() override {
|
||||
std::lock_guard<std::mutex> lock(instanceMutex);
|
||||
if (instance == this) {
|
||||
instance = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void reloadConfig() override {
|
||||
fcitx::readAsIni(config_, "conf/cloudpinyin.conf");
|
||||
}
|
||||
|
||||
const fcitx::Configuration *getConfig() const override { return &config_; }
|
||||
|
||||
void setConfig(const fcitx::RawConfig &config) override {
|
||||
config_.load(config, true);
|
||||
fcitx::safeSaveAsIni(config_, "conf/cloudpinyin.conf");
|
||||
}
|
||||
|
||||
void request(const std::string &pinyin, CloudPinyinCallback callback) {
|
||||
if (static_cast<int>(pinyin.size()) < config_.minimumLength.value()) {
|
||||
callback(pinyin, "");
|
||||
return;
|
||||
}
|
||||
if (errorCount_ >= MAX_ERROR) {
|
||||
callback(pinyin, "");
|
||||
return;
|
||||
}
|
||||
if (auto *cached = cache_.find(pinyin)) {
|
||||
callback(pinyin, *cached);
|
||||
return;
|
||||
}
|
||||
|
||||
const auto requestId = nextRequestId_.fetch_add(1);
|
||||
const auto backend = config_.backend.value();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(requestMutex_);
|
||||
pending_.emplace(requestId, PendingRequest{pinyin, std::move(callback), backend});
|
||||
}
|
||||
if (!requestIpc(requestId, pinyin, backendName(backend),
|
||||
config_.proxy.value())) {
|
||||
complete(requestId, 0, {}, "No cloud pinyin provider");
|
||||
}
|
||||
}
|
||||
|
||||
const fcitx::KeyList &toggleKey() const { return config_.toggleKey.value(); }
|
||||
|
||||
void resetError() {
|
||||
errorCount_ = 0;
|
||||
resetError_->setEnabled(false);
|
||||
}
|
||||
|
||||
void complete(uint64_t requestId, int httpStatus, std::vector<char> response,
|
||||
std::string error) {
|
||||
PendingRequest request;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(requestMutex_);
|
||||
auto iter = pending_.find(requestId);
|
||||
if (iter == pending_.end()) {
|
||||
return;
|
||||
}
|
||||
request = std::move(iter->second);
|
||||
pending_.erase(iter);
|
||||
}
|
||||
dispatcher_.scheduleWithContext(
|
||||
watch(), [this, request = std::move(request), httpStatus,
|
||||
response = std::move(response), error = std::move(error)]() mutable {
|
||||
if (httpStatus != 200 || !error.empty()) {
|
||||
errorCount_++;
|
||||
if (errorCount_ == MAX_ERROR) {
|
||||
FCITX_ERROR() << "Cloud pinyin reaches max error. "
|
||||
"Retry in 5 minutes.";
|
||||
resetError_->setNextInterval(MINUTE_IN_US * 5);
|
||||
resetError_->setOneShot();
|
||||
}
|
||||
}
|
||||
const auto hanzi = httpStatus == 200 && error.empty()
|
||||
? parseResult(request.backend, response)
|
||||
: std::string();
|
||||
request.callback(request.pinyin, hanzi);
|
||||
if (!hanzi.empty()) {
|
||||
cache_.insert(request.pinyin, hanzi);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
void providerUnavailable() {
|
||||
std::vector<uint64_t> requestIds;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(requestMutex_);
|
||||
requestIds.reserve(pending_.size());
|
||||
for (const auto &[requestId, _] : pending_) {
|
||||
requestIds.push_back(requestId);
|
||||
}
|
||||
}
|
||||
for (const auto requestId : requestIds) {
|
||||
complete(requestId, 0, {}, "Cloud pinyin provider disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
struct PendingRequest {
|
||||
std::string pinyin;
|
||||
CloudPinyinCallback callback;
|
||||
CloudPinyinBackend backend;
|
||||
};
|
||||
|
||||
bool requestIpc(uint64_t requestId, const std::string &pinyin,
|
||||
const std::string &backend, const std::string &proxy) {
|
||||
bool attached = false;
|
||||
auto *env = getEnv(&attached);
|
||||
if (!env || !bridgeClass || !requestMethod) {
|
||||
return false;
|
||||
}
|
||||
const auto pinyinValue = env->NewStringUTF(pinyin.c_str());
|
||||
const auto backendValue = env->NewStringUTF(backend.c_str());
|
||||
const auto proxyValue = env->NewStringUTF(proxy.c_str());
|
||||
const auto accepted = env->CallStaticBooleanMethod(
|
||||
bridgeClass, requestMethod, static_cast<jlong>(requestId),
|
||||
pinyinValue, backendValue, proxyValue);
|
||||
env->DeleteLocalRef(pinyinValue);
|
||||
env->DeleteLocalRef(backendValue);
|
||||
env->DeleteLocalRef(proxyValue);
|
||||
const bool failed = env->ExceptionCheck();
|
||||
if (failed) {
|
||||
env->ExceptionClear();
|
||||
}
|
||||
if (attached) {
|
||||
javaVm->DetachCurrentThread();
|
||||
}
|
||||
return !failed && accepted;
|
||||
}
|
||||
|
||||
FCITX_ADDON_EXPORT_FUNCTION(CloudPinyin, request);
|
||||
FCITX_ADDON_EXPORT_FUNCTION(CloudPinyin, toggleKey);
|
||||
FCITX_ADDON_EXPORT_FUNCTION(CloudPinyin, resetError);
|
||||
|
||||
fcitx::EventLoop *eventLoop_;
|
||||
fcitx::EventDispatcher &dispatcher_;
|
||||
std::unique_ptr<fcitx::EventSourceTime> resetError_;
|
||||
CloudPinyinConfig config_;
|
||||
std::atomic<uint64_t> nextRequestId_{1};
|
||||
std::mutex requestMutex_;
|
||||
std::unordered_map<uint64_t, PendingRequest> pending_;
|
||||
LRUCache<std::string, std::string> cache_{2048};
|
||||
int errorCount_ = 0;
|
||||
};
|
||||
|
||||
extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *) {
|
||||
javaVm = vm;
|
||||
JNIEnv *env = nullptr;
|
||||
if (vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK) {
|
||||
return JNI_ERR;
|
||||
}
|
||||
auto localClass = env->FindClass(BRIDGE_CLASS);
|
||||
if (!localClass) {
|
||||
return JNI_ERR;
|
||||
}
|
||||
bridgeClass = static_cast<jclass>(env->NewGlobalRef(localClass));
|
||||
env->DeleteLocalRef(localClass);
|
||||
requestMethod = env->GetStaticMethodID(
|
||||
bridgeClass, "request",
|
||||
"(JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)Z");
|
||||
return requestMethod ? JNI_VERSION_1_6 : JNI_ERR;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_org_fcitx_fcitx5_android_core_CloudPinyinIpc_nativeOnResponse(
|
||||
JNIEnv *env, jclass, jlong requestId, jint httpStatus, jbyteArray response,
|
||||
jstring error) {
|
||||
std::vector<char> data;
|
||||
const auto size = env->GetArrayLength(response);
|
||||
data.resize(size);
|
||||
env->GetByteArrayRegion(response, 0, size,
|
||||
reinterpret_cast<jbyte *>(data.data()));
|
||||
const char *errorChars = env->GetStringUTFChars(error, nullptr);
|
||||
const std::string errorValue(errorChars);
|
||||
env->ReleaseStringUTFChars(error, errorChars);
|
||||
|
||||
std::lock_guard<std::mutex> lock(instanceMutex);
|
||||
if (instance) {
|
||||
instance->complete(static_cast<uint64_t>(requestId), httpStatus,
|
||||
std::move(data), errorValue);
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_org_fcitx_fcitx5_android_core_CloudPinyinIpc_nativeOnProviderUnavailable(
|
||||
JNIEnv *, jclass) {
|
||||
std::lock_guard<std::mutex> lock(instanceMutex);
|
||||
if (instance) {
|
||||
instance->providerUnavailable();
|
||||
}
|
||||
}
|
||||
|
||||
class CloudPinyinFactory : public fcitx::AddonFactory {
|
||||
public:
|
||||
fcitx::AddonInstance *create(fcitx::AddonManager *manager) override {
|
||||
return new CloudPinyin(manager);
|
||||
}
|
||||
};
|
||||
|
||||
FCITX_ADDON_FACTORY_V2(cloudpinyin, CloudPinyinFactory);
|
||||
@ -0,0 +1,143 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
|
||||
*/
|
||||
package org.fcitx.fcitx5.android.plugin.cloud_pinyin
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fcitx.fcitx5.android.common.FcitxPluginService
|
||||
import org.fcitx.fcitx5.android.common.ipc.FcitxRemoteConnection
|
||||
import org.fcitx.fcitx5.android.common.ipc.ICloudPinyinCallback
|
||||
import org.fcitx.fcitx5.android.common.ipc.ICloudPinyinProvider
|
||||
import org.fcitx.fcitx5.android.common.ipc.bindFcitxRemoteService
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.Proxy
|
||||
import java.net.URI
|
||||
import java.net.URL
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class MainService : FcitxPluginService() {
|
||||
|
||||
private lateinit var connection: FcitxRemoteConnection
|
||||
private var scope = newScope()
|
||||
private val requests = ConcurrentHashMap<Long, Job>()
|
||||
|
||||
private val provider = object : ICloudPinyinProvider.Stub() {
|
||||
override fun request(
|
||||
requestId: Long,
|
||||
pinyin: String,
|
||||
backend: String,
|
||||
proxy: String,
|
||||
callback: ICloudPinyinCallback
|
||||
) {
|
||||
requests.remove(requestId)?.cancel()
|
||||
requests[requestId] = scope.launch {
|
||||
val result = fetch(pinyin, backend, proxy)
|
||||
try {
|
||||
callback.onResponse(requestId, result.status, result.body, result.error)
|
||||
} finally {
|
||||
requests.remove(requestId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (!scope.coroutineContext[Job]!!.isActive) {
|
||||
scope = newScope()
|
||||
}
|
||||
connection = bindFcitxRemoteService(BuildConfig.MAIN_APPLICATION_ID) {
|
||||
it.registerCloudPinyinProvider(provider)
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
requests.values.forEach { it.cancel() }
|
||||
requests.clear()
|
||||
scope.cancel()
|
||||
if (::connection.isInitialized) {
|
||||
runCatching {
|
||||
connection.remoteService?.unregisterCloudPinyinProvider(provider)
|
||||
}
|
||||
unbindService(connection)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetch(pinyin: String, backend: String, proxyValue: String): Result {
|
||||
val url = endpoint(pinyin, backend) ?: return Result(0, ByteArray(0), "Unknown backend")
|
||||
Log.d(TAG, "Cloud pinyin request: $url")
|
||||
return try {
|
||||
val connection = (URL(url).openConnection(parseProxy(proxyValue)) as HttpURLConnection)
|
||||
connection.connectTimeout = REQUEST_TIMEOUT_MS
|
||||
connection.readTimeout = REQUEST_TIMEOUT_MS
|
||||
connection.instanceFollowRedirects = true
|
||||
connection.requestMethod = "GET"
|
||||
connection.useCaches = false
|
||||
val status = connection.responseCode
|
||||
val stream = if (status in 200..299) connection.inputStream else connection.errorStream
|
||||
val body = stream?.use(::readResponse) ?: ByteArray(0)
|
||||
if (status in 200..299) {
|
||||
Log.d(TAG, "Cloud pinyin response ($status): ${String(body, StandardCharsets.UTF_8)}")
|
||||
}
|
||||
connection.disconnect()
|
||||
Result(status, body, "")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Cloud pinyin request failed: ${e.javaClass.simpleName}: ${e.message}", e)
|
||||
Result(0, ByteArray(0), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun endpoint(pinyin: String, backend: String): String? {
|
||||
val encoded = java.net.URLEncoder.encode(pinyin, StandardCharsets.UTF_8.name())
|
||||
.replace("+", "%20")
|
||||
return when (backend) {
|
||||
"Google" -> "https://www.google.com/inputtools/request?ime=pinyin&text=$encoded"
|
||||
"GoogleCN" -> "https://www.google.cn/inputtools/request?ime=pinyin&text=$encoded"
|
||||
"Baidu" -> "https://olimenew.baidu.com/py?input=$encoded&inputtype=py&resultcoding=utf-8"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseProxy(value: String): Proxy {
|
||||
if (value.isBlank()) return Proxy.NO_PROXY
|
||||
val uri = URI(value)
|
||||
val host = requireNotNull(uri.host) { "Proxy host is missing" }
|
||||
require(uri.port in 1..65535) { "Proxy port is invalid" }
|
||||
val type = when (uri.scheme.lowercase()) {
|
||||
"http", "https" -> Proxy.Type.HTTP
|
||||
"socks", "socks4", "socks5" -> Proxy.Type.SOCKS
|
||||
else -> error("Unsupported proxy scheme")
|
||||
}
|
||||
return Proxy(type, java.net.InetSocketAddress(host, uri.port))
|
||||
}
|
||||
|
||||
private fun readResponse(input: java.io.InputStream): ByteArray {
|
||||
val output = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(512)
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
require(output.size() + count <= MAX_RESPONSE_SIZE) { "Response exceeds 2048 bytes" }
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
private fun newScope() = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private data class Result(val status: Int, val body: ByteArray, val error: String)
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CloudPinyinService"
|
||||
const val REQUEST_TIMEOUT_MS = 10_000
|
||||
const val MAX_RESPONSE_SIZE = 2048
|
||||
}
|
||||
}
|
||||
6
plugin/cloud-pinyin/src/main/res/values/strings.xml
Normal file
6
plugin/cloud-pinyin/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name_debug">Fcitx5 (Cloud Pinyin Plugin | Debug)</string>
|
||||
<string name="app_name_release">Fcitx5 (Cloud Pinyin Plugin)</string>
|
||||
<string name="description">Cloud Pinyin module with network access</string>
|
||||
</resources>
|
||||
7
plugin/cloud-pinyin/src/main/res/xml/plugin.xml
Normal file
7
plugin/cloud-pinyin/src/main/res/xml/plugin.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<plugin xmlns="../../../../../pluginSchema.xsd">
|
||||
<apiVersion>0.1</apiVersion>
|
||||
<domain>fcitx5-chinese-addons</domain>
|
||||
<description>@string/description</description>
|
||||
<hasService>true</hasService>
|
||||
</plugin>
|
||||
@ -28,6 +28,7 @@ include(":app")
|
||||
include(":lib:plugin-base")
|
||||
include(":plugin:anthy")
|
||||
include(":plugin:clipboard-filter")
|
||||
include(":plugin:cloud-pinyin")
|
||||
include(":plugin:unikey")
|
||||
include(":plugin:rime")
|
||||
include(":plugin:hangul")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user