diff --git a/app/src/main/java/org/fcitx/fcitx5/android/FcitxRemoteService.kt b/app/src/main/java/org/fcitx/fcitx5/android/FcitxRemoteService.kt index 9e292e04..ac3c9bab 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/FcitxRemoteService.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/FcitxRemoteService.kt @@ -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() + 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() } } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/CloudPinyinIpc.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/CloudPinyinIpc.kt new file mode 100644 index 00000000..1e9c9c0a --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/CloudPinyinIpc.kt @@ -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() +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/Fcitx.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/Fcitx.kt index 3a2aa0b4..27eee2c8 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/Fcitx.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/Fcitx.kt @@ -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: diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PreferenceScreenFactory.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PreferenceScreenFactory.kt index b0097ad8..e2ee15c8 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PreferenceScreenFactory.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PreferenceScreenFactory.kt @@ -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() diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigDescriptor.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigDescriptor.kt index 521358ef..346ca1c0 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigDescriptor.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigDescriptor.kt @@ -181,6 +181,7 @@ sealed class ConfigDescriptor : Parcelable { Punctuation, QuickPhrase, Chttrans, + CloudPinyin, TableGlobal, PinyinCustomPhrase, RimeUserDataDir, @@ -322,6 +323,7 @@ sealed class ConfigDescriptor : 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 diff --git a/lib/common/src/main/aidl/org/fcitx/fcitx5/android/common/ipc/ICloudPinyinCallback.aidl b/lib/common/src/main/aidl/org/fcitx/fcitx5/android/common/ipc/ICloudPinyinCallback.aidl new file mode 100644 index 00000000..393250b0 --- /dev/null +++ b/lib/common/src/main/aidl/org/fcitx/fcitx5/android/common/ipc/ICloudPinyinCallback.aidl @@ -0,0 +1,5 @@ +package org.fcitx.fcitx5.android.common.ipc; + +interface ICloudPinyinCallback { + void onResponse(long requestId, int httpStatus, in byte[] response, String error); +} diff --git a/lib/common/src/main/aidl/org/fcitx/fcitx5/android/common/ipc/ICloudPinyinProvider.aidl b/lib/common/src/main/aidl/org/fcitx/fcitx5/android/common/ipc/ICloudPinyinProvider.aidl new file mode 100644 index 00000000..eca429fd --- /dev/null +++ b/lib/common/src/main/aidl/org/fcitx/fcitx5/android/common/ipc/ICloudPinyinProvider.aidl @@ -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); +} diff --git a/lib/common/src/main/aidl/org/fcitx/fcitx5/android/common/ipc/IFcitxRemoteService.aidl b/lib/common/src/main/aidl/org/fcitx/fcitx5/android/common/ipc/IFcitxRemoteService.aidl index 3a260816..fc6bca1d 100644 --- a/lib/common/src/main/aidl/org/fcitx/fcitx5/android/common/ipc/IFcitxRemoteService.aidl +++ b/lib/common/src/main/aidl/org/fcitx/fcitx5/android/common/ipc/IFcitxRemoteService.aidl @@ -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 */ diff --git a/plugin/cloud-pinyin/build.gradle.kts b/plugin/cloud-pinyin/build.gradle.kts new file mode 100644 index 00000000..60041404 --- /dev/null +++ b/plugin/cloud-pinyin/build.gradle.kts @@ -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) +} diff --git a/plugin/cloud-pinyin/proguard-rules.pro b/plugin/cloud-pinyin/proguard-rules.pro new file mode 100644 index 00000000..2f109adb --- /dev/null +++ b/plugin/cloud-pinyin/proguard-rules.pro @@ -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 diff --git a/plugin/cloud-pinyin/src/main/AndroidManifest.xml b/plugin/cloud-pinyin/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1812e5fb --- /dev/null +++ b/plugin/cloud-pinyin/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/plugin/cloud-pinyin/src/main/cpp/CMakeLists.txt b/plugin/cloud-pinyin/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..ae9151f3 --- /dev/null +++ b/plugin/cloud-pinyin/src/main/cpp/CMakeLists.txt @@ -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) diff --git a/plugin/cloud-pinyin/src/main/cpp/cloud-pinyin/CMakeLists.txt b/plugin/cloud-pinyin/src/main/cpp/cloud-pinyin/CMakeLists.txt new file mode 100644 index 00000000..df945d14 --- /dev/null +++ b/plugin/cloud-pinyin/src/main/cpp/cloud-pinyin/CMakeLists.txt @@ -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) diff --git a/plugin/cloud-pinyin/src/main/cpp/cloud-pinyin/cloudpinyin.conf.in.in b/plugin/cloud-pinyin/src/main/cpp/cloud-pinyin/cloudpinyin.conf.in.in new file mode 100644 index 00000000..90ff49fe --- /dev/null +++ b/plugin/cloud-pinyin/src/main/cpp/cloud-pinyin/cloudpinyin.conf.in.in @@ -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@ diff --git a/plugin/cloud-pinyin/src/main/cpp/cloud-pinyin/cloudpinyin.cpp b/plugin/cloud-pinyin/src/main/cpp/cloud-pinyin/cloudpinyin.cpp new file mode 100644 index 00000000..63620858 --- /dev/null +++ b/plugin/cloud-pinyin/src/main/cpp/cloud-pinyin/cloudpinyin.cpp @@ -0,0 +1,382 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2017-2017 CSSlayer + * 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class CloudPinyin; +std::mutex instanceMutex; +CloudPinyin *instance = nullptr; + +FCITX_CONFIG_ENUM(CloudPinyinBackend, Google, GoogleCN, Baidu); +FCITX_CONFIGURATION( + CloudPinyinConfig, + fcitx::Option toggleKey{ + this, "Toggle Key", _("Toggle Key"), {fcitx::Key("Control+Alt+Shift+C")}}; + fcitx::Option minimumLength{ + this, "MinimumPinyinLength", _("Minimum Pinyin Length"), 4}; + fcitx::Option backend{ + this, "Backend", _("Backend"), CloudPinyinBackend::GoogleCN}; + fcitx::OptionWithAnnotation 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 &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(&env), JNI_VERSION_1_6) == + JNI_EDETACHED) { + if (javaVm->AttachCurrentThread(&env, nullptr) != JNI_OK) { + return nullptr; + } + *attached = true; + } + return env; +} + +} // namespace + +template 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 order_; + std::unordered_map::iterator>> + entries_; +}; + +class CloudPinyin : public fcitx::AddonInstance, + public fcitx::TrackableObject { +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 lock(instanceMutex); + instance = this; + reloadConfig(); + } + + ~CloudPinyin() override { + std::lock_guard 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(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 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 response, + std::string error) { + PendingRequest request; + { + std::lock_guard 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 requestIds; + { + std::lock_guard 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(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 resetError_; + CloudPinyinConfig config_; + std::atomic nextRequestId_{1}; + std::mutex requestMutex_; + std::unordered_map pending_; + LRUCache cache_{2048}; + int errorCount_ = 0; +}; + +extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *) { + javaVm = vm; + JNIEnv *env = nullptr; + if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) { + return JNI_ERR; + } + auto localClass = env->FindClass(BRIDGE_CLASS); + if (!localClass) { + return JNI_ERR; + } + bridgeClass = static_cast(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 data; + const auto size = env->GetArrayLength(response); + data.resize(size); + env->GetByteArrayRegion(response, 0, size, + reinterpret_cast(data.data())); + const char *errorChars = env->GetStringUTFChars(error, nullptr); + const std::string errorValue(errorChars); + env->ReleaseStringUTFChars(error, errorChars); + + std::lock_guard lock(instanceMutex); + if (instance) { + instance->complete(static_cast(requestId), httpStatus, + std::move(data), errorValue); + } +} + +extern "C" JNIEXPORT void JNICALL +Java_org_fcitx_fcitx5_android_core_CloudPinyinIpc_nativeOnProviderUnavailable( + JNIEnv *, jclass) { + std::lock_guard 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); diff --git a/plugin/cloud-pinyin/src/main/java/org/fcitx/fcitx5/android/plugin/cloud_pinyin/MainService.kt b/plugin/cloud-pinyin/src/main/java/org/fcitx/fcitx5/android/plugin/cloud_pinyin/MainService.kt new file mode 100644 index 00000000..8b473709 --- /dev/null +++ b/plugin/cloud-pinyin/src/main/java/org/fcitx/fcitx5/android/plugin/cloud_pinyin/MainService.kt @@ -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() + + 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 + } +} diff --git a/plugin/cloud-pinyin/src/main/res/values/strings.xml b/plugin/cloud-pinyin/src/main/res/values/strings.xml new file mode 100644 index 00000000..48f9e1e8 --- /dev/null +++ b/plugin/cloud-pinyin/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Fcitx5 (Cloud Pinyin Plugin | Debug) + Fcitx5 (Cloud Pinyin Plugin) + Cloud Pinyin module with network access + diff --git a/plugin/cloud-pinyin/src/main/res/xml/plugin.xml b/plugin/cloud-pinyin/src/main/res/xml/plugin.xml new file mode 100644 index 00000000..b0255d19 --- /dev/null +++ b/plugin/cloud-pinyin/src/main/res/xml/plugin.xml @@ -0,0 +1,7 @@ + + + 0.1 + fcitx5-chinese-addons + @string/description + true + diff --git a/settings.gradle.kts b/settings.gradle.kts index 7744dd60..64b81f12 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")