Merge 7d029911cc0d2fb964fcb46167a473f65c954c24 into bcb694384de8462302448cab6a3dfb1853ba5d5e

This commit is contained in:
Qijia Liu 2026-08-01 11:47:11 -04:00 committed by GitHub
commit c4a11a53e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 800 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
package org.fcitx.fcitx5.android.common.ipc;
interface ICloudPinyinCallback {
void onResponse(long requestId, int httpStatus, in byte[] response, String error);
}

View File

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

View File

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

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

View 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

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

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

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

View File

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

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

View File

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

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

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

View File

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