feat: 发布4×4候选与Android手表应用内键盘组件

这个提交包含在:
sydxky 于 2026-11-14 13:55:53 +08:00 提交
1 父节点 82aeeba816
当前提交 87aa966a51
86 个文件已更改
+2697 -2975

没有与搜索条件相匹配的文件

+13
查看文件
@@ -0,0 +1,13 @@
.gradle/
local.properties
build/
app/build/
keyboard/src/main/assets/dictionary.db
artifacts/
release-signing.properties
*.jks
*.keystore
**/__pycache__/
keyboard/build/
host/build/
+23
查看文件
@@ -0,0 +1,23 @@
MIT License
Copyright (c) 2026 sydxky
Copyright (c) 2026 kqakqakqa
Copyright (c) 2026 crabKun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17
查看文件
@@ -0,0 +1,17 @@
# 蟹蟹输入 Android手表适配改版
本项目由sydxky基于[CrabKeyboard / 蟹蟹输入](https://github.com/crabPangXie/crabKeyboard)的MIT词库重新实现Android应用内键盘组件、圆屏布局、九宫/14键消歧及候选分页。不是上游原版,也不表示上游作者或设备厂商对本改版作出背书。
上游固定提交:`93d36b5002dee80a3d253b1ec7ec9e29800a104c`。保留原作者kqakqakqa、crabKun的版权声明和MIT许可证;原始词库位于同仓库RTOS目录。Android自研适配部分以[MIT许可证](./LICENSE)发布,**允许商用、修改、再分发和闭源集成**,须保留适用版权及许可全文。
第三方内容继续按各自许可证使用:
| 内容 | 作者/项目 | 许可与位置 |
| --- | --- | --- |
| CrabKeyboard V5词库 | kqakqakqa、crabKun | MIT,`keyboard/src/main/assets/licenses/crabKeyboard-LICENSE.txt` |
| 本地候选排序词频 | jieba 0.42.1 / Sun Junyi | MIT,`keyboard/src/main/assets/licenses/jieba-LICENSE.txt` |
| Gradle wrapper | Gradle | Apache-2.0,`keyboard/src/main/assets/licenses/Gradle-LICENSE.txt` |
14键参考公开演示中的相邻字母分组,自行实现离线消歧;不包含华为内部模型或固件代码。Android SDK、JDK和构建工具不随源码分发,按各自许可使用。AndroidX及JUnit仅用于本地测试,不打入正式APK。
公开仓库:<https://git2.sydxky.cn/sydxky/crab-input-rtos>。匿名问题与建议:<https://git2.sydxky.cn/feedback/sydxky/crab-input-rtos>。
+58
查看文件
@@ -0,0 +1,58 @@
# 蟹蟹输入 Android应用内键盘组件
基于CrabKeyboard MIT词库,由herry重写的Android手表原生键盘组件,1.2.0 / 10200。提供可集成的 **AAR**、演示APK和独立接入示例;适用Android 8.1 / API 27及以上。
应用接入后,点击自己的输入框自动展开内置键盘。**不注册系统输入法、不要求用户切换输入法、不改变系统默认输入法。** 圆屏按每行所在高度计算安全弦宽;4×4完整候选,单字16个一页,词组扩宽或增加行高,候选不显示拼音。九宫、标准QWERTY全拼/英文、14键、数字、三页符号、Unicode删除、按住连删及光标编辑。
完全离线,无网络或外部存储权限;组件不持久化输入内容,不启用学习。密码输入默认英文并遮蔽预览。页面隐藏/销毁时清除组件会话与候选,宿主输入框中的内容仍由宿主决定如何处理。异步查询按会话序号隔离,旧结果不能回填新的输入框。
## 一分钟接入
将发行版`crab-keyboard-1.2.0.aar`放入宿主`app/libs/`:
```groovy
dependencies { implementation files('libs/crab-keyboard-1.2.0.aar') }
```
```java
import com.sydxky.crabinput.CrabKeyboard;
// Activity 的 UI 线程,已有 EditText;attach 返回一个可关闭绑定。
CrabKeyboard.Binding keyboard = CrabKeyboard.attach(this, editText);
// 已有自己的焦点/点击处理时,可在相应事件调用 keyboard.show()。
// Activity 销毁时可显式 keyboard.close(),重复关闭安全。
```
`attach`会接管该输入框的焦点和点击监听器,并关闭此框的系统软键盘自动显示。`show()`、`dismiss()`、`close()`在UI线程调用。自动监听宿主Activity停止和销毁,停止时关闭会话,关闭绑定时恢复输入框原来的软键盘显示属性。组件通过该输入框的Editable/InputConnection回填,支持选区和数字/密码/网址InputType。窗口为宿主应用自己的Dialog,不申请悬浮窗权限。
需要保留自定义监听器时,先取得绑定,再在自定义事件中调用`keyboard.show()`;不要重复绑定同一个输入框。`host/`是独立包名的最小接入示例,`app/`是完整演示。未接入的应用继续使用原有系统输入法。
## 模块与构建
| 模块 | 用途 |
| --- | --- |
| `keyboard/` | 原生公开组件、词库、MIT许可,生成AAR |
| `app/` | 圆屏演示APK,包名`com.sydxky.crabinput` |
| `host/` | 独立应用集成示例,证明组件可由其他应用接入 |
准备JDK 17、Android SDK compile 37及Python 3。词库位于同仓库RTOS目录,SDK/私钥不随源码分发。
```powershell
python scripts/build-dictionary.py
.\gradlew.bat :keyboard:testDebugUnitTest :keyboard:lintRelease :keyboard:assembleRelease :app:lintRelease :app:assembleDebug :host:assembleDebug
# 演示APK正式签名由本机受保护文件提供;组件AAR无须应用签名。
.\gradlew.bat :app:assembleRelease -PreleaseSigningProperties=<本机签名配置路径>
```
输出`keyboard/build/outputs/aar/keyboard-release.aar`。SQLite词库无损转换并全量回读104,837组索引、163,238候选;数据库是可生成的本地资产,不进入Git。正式APK必须使用已批准签名,签名材料不公开。
## 真机验证
短时验收在已连接的OWW261上使用真实EditText和公开组件,通过UiAutomator点击操作,不直接调用键盘内部状态代替交互;比较测试前后系统默认输入法保持一致。范围及截图见[1.2.0验收](./VALIDATION_1.2.0.md)。演示应用内即可测试,无需启用或切换系统输入法。
```powershell
.\gradlew.bat :app:assembleAndroidTest :host:assembleDebug -PimeTestBuildType=release -PreleaseSigningProperties=<本机签名配置路径>
python scripts/validate-device.py --serial <已连接手表序列号> --output <本地证据目录>
```
源码、截图及下载:<https://git2.sydxky.cn/sydxky/crab-input-rtos>。MIT允许商用,保留[LICENSE](./LICENSE)、[NOTICE](./NOTICE.md)及词库/jieba许可。AndroidX/JUnit仅用于测试;AAR无第三方运行时依赖。当前是组件和演示验收,未进行商店审核。
+28
查看文件
@@ -0,0 +1,28 @@
# Android 应用内键盘 1.2.0 验收
日期:2026-11-14。OWW261 真机,Android 11 / API 30,466×466。正式演示 APK 包名 `com.sydxky.crabinput`,版本 1.2.0 / 10200,最低系统 API 27。
该版本提供 `keyboard` 原生 AAR,以及演示 APK、独立包名接入示例。组件通过宿主 Dialog 展开、直接回填 EditText,不包含系统输入法 Service。测试前后系统默认输入法一致。
| 真实交互项目 | 结果 |
| --- | --- |
| 完整词库在全拼、九宫、14 键中查询 | 通过 |
| 密码及数字输入框选择安全模式 | 通过 |
| Unicode 码点删除、按住连删及松手停止 | 通过 |
| 独立应用接入公开组件并回填 | 通过 |
| 14 键歧义及词组占宽显示 | 通过 |
| 全拼连续输入“你好世界” | 通过 |
| 九宫词组及删除回填 | 通过 |
| 单字 4×4、全部候选翻页、末页选词及圆屏安全边界 | 通过 |
最终验收 8/8,通过实际 UiAutomator 按钮操作;没有通过调用键盘内部状态代替交互。候选查询完成时控件树会更新,测试仅在注入点击前遇到失效节点时重新查找,行为断言保持严格。截图为最终正式 APK 的实际设备画面。演示首页显式设置输入框内边距及背景,消除厂商默认 EditText 内边距导致的文字裁切。
最终演示 APK SHA-256:
```text
4e2b0ce4a9d82ae6994173cd17472ee4e711a49bc33c5c8e6c202c2fd4857271
```
组件 4 项 Java 单元测试、组件/演示应用 release lint、AAR 和签名 APK 构建通过。SQLite `integrity_check`、全部 104,837 组索引/163,238 候选回读通过。AAR 无第三方运行时依赖,不申请网络、外部存储或悬浮窗权限。
本次为短时交互验收,未进行长期压测、功耗或商店审核。系统默认输入法始终由用户原有设置决定,组件只在已接入它的应用中使用。
+60
查看文件
@@ -0,0 +1,60 @@
import java.util.Properties
plugins { id 'com.android.application' }
def signingPath = providers.gradleProperty('releaseSigningProperties').orElse('release-signing.properties').get()
def signingFile = rootProject.file(signingPath)
def signing = new Properties()
if (signingFile.isFile()) signingFile.withInputStream { signing.load(it) }
def approved = signing.getProperty('releaseApproved','false').toBoolean()
android {
namespace 'com.sydxky.crabinput'
compileSdk 37
defaultConfig {
applicationId 'com.sydxky.crabinput'
minSdk 27
targetSdk 35
versionCode 10200
versionName '1.2.0'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
signingConfigs {
if (approved) {
release {
storeFile new File(signingFile.parentFile, signing.getProperty('storeFile',''))
storePassword signing.getProperty('storePassword','')
keyAlias signing.getProperty('keyAlias','')
keyPassword signing.getProperty('keyPassword','')
enableV1Signing false
enableV2Signing true
enableV3Signing false
enableV4Signing false
}
}
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
if (approved) signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
testProguardFiles 'proguard-test.pro'
}
}
compileOptions { sourceCompatibility JavaVersion.VERSION_11; targetCompatibility JavaVersion.VERSION_11 }
lint { abortOnError true }
testBuildType providers.gradleProperty('imeTestBuildType').orElse('debug').get()
}
tasks.register('checkReleaseSigning') {
doLast {
if (!approved || !signingFile.isFile() || !android.signingConfigs.release.storeFile.isFile())
throw new GradleException('Supply approved local signing with -PreleaseSigningProperties=<protected-file>')
}
}
tasks.matching { it.name in ['packageRelease','assembleRelease'] }.configureEach { dependsOn 'checkReleaseSigning' }
dependencies {
implementation project(':keyboard')
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
androidTestImplementation 'com.google.errorprone:error_prone_annotations:2.36.0'
androidTestImplementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
}
+2
查看文件
@@ -0,0 +1,2 @@
-keep class com.sydxky.crabinput.Lexicon { public *; }
-keep class com.sydxky.crabinput.Candidate { public *; }
+7
查看文件
@@ -0,0 +1,7 @@
# error_prone annotation signatures refer to javac's Modifier enum. These are
# compile-time metadata in the test runner, never Android executable paths.
-dontwarn javax.lang.model.element.Modifier
# Keep the instrumented runner intact; production R8 rules remain independent.
-dontshrink
-dontobfuscate
-dontoptimize
@@ -0,0 +1,116 @@
package com.sydxky.crabinput;
import android.content.Context;
import android.content.Intent;
import android.graphics.Rect;
import android.os.SystemClock;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.uiautomator.By;
import androidx.test.uiautomator.UiDevice;
import androidx.test.uiautomator.UiObject2;
import androidx.test.uiautomator.UiScrollable;
import androidx.test.uiautomator.UiSelector;
import androidx.test.uiautomator.Until;
import org.junit.Before;
import org.junit.Test;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.Assert.*;
/** Uses the public embedded AAR and real editors, never calls page methods. */
public class ImeDeviceTest {
private UiDevice device;
private String keyboardPackage;
private static final String PKG="com.sydxky.crabinput";
@Before public void launch() throws Exception {
keyboardPackage=PKG;device=UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());device.wakeUp();
Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();
Intent intent=new Intent(context,MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_ACTIVITY_CLEAR_TASK);context.startActivity(intent);
assertTrue(device.wait(Until.hasObject(By.text("蟹蟹输入")),5000));
}
private void editor(String description) throws Exception {
UiObject2 field=device.findObject(By.desc(description));
if(field==null){new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().description(description));field=device.findObject(By.desc(description));}
assertNotNull(field);field.click();assertTrue("Embedded keyboard auto-opens",device.wait(Until.hasObject(By.desc("蟹蟹输入键盘")),5000));SystemClock.sleep(500);
}
private void tap(String text){clickFresh(By.pkg(keyboardPackage).text(text),text);SystemClock.sleep(95);}
private void action(String description){clickFresh(By.pkg(keyboardPackage).desc(description),description);SystemClock.sleep(150);}
private void clickFresh(androidx.test.uiautomator.BySelector selector,String label){
// A dictionary result may rebuild the view tree between lookup and tap.
// Retry only a stale node detected BEFORE click injection, never a
// failed behavioral assertion or an already-injected user operation.
for(int attempt=0;attempt<3;attempt++){
UiObject2 item=device.wait(Until.findObject(selector),5000);assertNotNull("Missing button "+label,item);
try{item.click();return;}catch(androidx.test.uiautomator.StaleObjectException error){if(attempt==2)throw error;SystemClock.sleep(150);}
}
}
private void type(String text){for(int i=0;i<text.length();i++)tap(text.substring(i,i+1));}
private List<UiObject2> singles(){List<UiObject2> result=new ArrayList<>();for(UiObject2 button:device.findObjects(By.pkg(PKG).clazz("android.widget.Button"))){String text=button.getText();if(text!=null&&text.length()==1&&Character.UnicodeScript.of(text.charAt(0))==Character.UnicodeScript.HAN)result.add(button);}return result;}
@Test public void fullGridAndEveryPageAreUsable() throws Exception {
editor("demo_text");tap("26键");type("shi");action("更多候选");
assertFalse("No reading is rendered on expanded pages",device.hasObject(By.pkg(PKG).text("shi")));
List<UiObject2> cells=singles();assertEquals(16,cells.size());Set<Integer> rows=new HashSet<>();
for(UiObject2 cell:cells){Rect r=cell.getVisibleBounds();rows.add(r.centerY());assertTrue(r.width()>35&&r.height()>30);assertRoundSafe(r);}
assertEquals(4,rows.size());
UiObject2 counter=device.findObject(By.pkg(PKG).text(java.util.regex.Pattern.compile("[0-9]+ 项.*[0-9]+ / [0-9]+")));
assertNotNull(counter);int pageCount=Integer.parseInt(counter.getText().substring(counter.getText().lastIndexOf('/')+1).trim());
Set<String> words=new HashSet<>();String last="";
for(int page=0;page<pageCount;page++){
for(UiObject2 cell:device.findObjects(By.pkg(PKG).clazz("android.widget.Button"))){String text=cell.getText();if(text!=null&&!text.isEmpty()&&!text.equals("键盘")&&!text.equals("编辑")&&Character.UnicodeScript.of(text.codePointAt(0))==Character.UnicodeScript.HAN){words.add(text);last=text;}}
if(page<pageCount-1)action("下一页");
}
assertTrue("Complete dictionary remains reachable",words.size()>=80);tap(last);action("收起键盘");
UiObject2 field=device.findObject(By.desc("demo_text"));assertNotNull(field);assertEquals(last,field.getText());
}
private void assertRoundSafe(Rect r){
Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();if(!context.getResources().getConfiguration().isScreenRound())return;
float cx=device.getDisplayWidth()/2f,cy=device.getDisplayHeight()/2f,limit=Math.min(cx,cy)-3;
for(int x:new int[]{r.left,r.right})for(int y:new int[]{r.top,r.bottom})assertTrue("Button stays inside round screen: "+r,Math.hypot(x-cx,y-cy)<=limit);
}
@Test public void fullPinyinContinuousInputCommitsIntoEditor() throws Exception {
editor("demo_text");tap("26键");type("nihaoshijie");tap("你好");tap("世界");action("完成");
assertEquals("你好世界",device.findObject(By.desc("demo_text")).getText());
}
@Test public void fourteenKeysAndWidePhraseCandidates() throws Exception {
editor("demo_text");tap("14键");for(String key:new String[]{"BN","UI","GH","AS","OP"})tap(key);
assertNotNull(device.wait(Until.findObject(By.pkg(PKG).text("你好")),5000));action("更多候选");
List<UiObject2> phrases=device.findObjects(By.pkg(PKG).clazz("android.widget.Button"));boolean found=false;
for(UiObject2 button:phrases)if("你好".equals(button.getText())){assertTrue(button.getVisibleBounds().width()>device.getDisplayWidth()/3);found=true;}
assertTrue(found);tap("你好");action("完成");assertEquals("你好",device.findObject(By.desc("demo_text")).getText());
}
@Test public void t9PhraseAndUnicodeDelete() throws Exception {
editor("demo_text");for(String key:new String[]{"MNO","GHI","GHI","ABC","MNO"})tap(key);tap("你好");
action("删除");action("收起键盘");assertEquals("你",device.findObject(By.desc("demo_text")).getText());
}
@Test public void passwordAndNumericEditorsChooseSafeModes() throws Exception {
editor("demo_password");assertNotNull(device.findObject(By.pkg(PKG).text("私密输入")));type("secret");
assertFalse(device.hasObject(By.pkg(PKG).text("secret")));action("收起键盘");
editor("demo_number");type("1230");action("完成");assertEquals("1230",device.findObject(By.desc("demo_number")).getText());
}
@Test public void allOriginalDictionaryWordsRemainQueryable() throws Exception {
Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();
try(Lexicon lexicon=new Lexicon(context)){
for(String[] word:new String[][]{{"nihao","你好"},{"shijie","世界"},{"zhongguo","中国"},{"shurufa","输入法"},{"beijing","北京"}})for(String mode:new String[]{"py","t9","14"}){
List<Candidate> result=lexicon.lookup(mode.equals("py")?word[0]:Lexicon.encode(word[0],mode),mode);
assertTrue(word[1]+" in "+mode,result.stream().anyMatch(item->item.word.equals(word[1])));
}
assertTrue(lexicon.lookup("shi","py").size()>=90);
}
}
@Test public void anotherApplicationEmbedsThePublicComponent() throws Exception {
keyboardPackage=PKG+".host";Intent fixture=new Intent().setComponent(new android.content.ComponentName(PKG+".host",PKG+".host.HostActivity")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
InstrumentationRegistry.getInstrumentation().getTargetContext().startActivity(fixture);
UiObject2 editor=device.wait(Until.findObject(By.desc("fixture_editor")),5000);assertNotNull(editor);editor.click();
assertTrue(device.wait(Until.hasObject(By.desc("蟹蟹输入键盘")),5000));tap("26键");type("nihao");tap("你好");action("收起键盘");
assertEquals("你好",device.findObject(By.desc("fixture_editor")).getText());
}
@Test public void unicodeAndHeldDeleteRespectTheRealEditor() throws Exception {
editor("demo_text");action("收起键盘");UiObject2 field=device.findObject(By.desc("demo_text"));field.setText("你😀");field.click();action("删除");action("收起键盘");
assertEquals("你",device.findObject(By.desc("demo_text")).getText());
field=device.findObject(By.desc("demo_text"));field.setText("abcdefghijklmnop");field.click();
UiObject2 delete=device.wait(Until.findObject(By.desc("删除")),5000);Rect bounds=delete.getVisibleBounds();device.swipe(bounds.centerX(),bounds.centerY(),bounds.centerX(),bounds.centerY(),160);SystemClock.sleep(150);action("收起键盘");
int count=device.findObject(By.desc("demo_text")).getText().length();assertTrue("Hold repeats deletion",count<14);SystemClock.sleep(300);assertEquals("Release stops repeat",count,device.findObject(By.desc("demo_text")).getText().length());
}
}
+10
查看文件
@@ -0,0 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.type.watch" />
<application android:label="蟹蟹输入" android:icon="@drawable/ic_launcher" android:theme="@style/AppTheme"
android:allowBackup="false" android:supportsRtl="true" android:usesCleartextTraffic="false">
<activity android:name=".MainActivity" android:exported="true" android:taskAffinity="com.sydxky.crabinput" android:windowSoftInputMode="adjustResize">
<intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" /></intent-filter>
</activity>
<meta-data android:name="com.google.android.wearable.standalone" android:value="true" />
</application>
</manifest>
@@ -0,0 +1,55 @@
package com.sydxky.crabinput;
import android.app.Activity;
import android.graphics.Color;
import android.os.Bundle;
import android.text.InputType;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
/** Public component demo: no system IME registration or setting changes. */
public final class MainActivity extends Activity {
private final List<CrabKeyboard.Binding> bindings=new ArrayList<>();
private float scale;
@android.annotation.SuppressLint("WearPasswordInput") // Local masking demo, never an account password prompt.
@Override public void onCreate(Bundle saved){
super.onCreate(saved);scale=getResources().getDisplayMetrics().widthPixels/410f;
boolean round=getResources().getConfiguration().isScreenRound();
if(round){getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN|View.SYSTEM_UI_FLAG_HIDE_NAVIGATION|View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);}
getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
ScrollView scroll=new ScrollView(this);scroll.setFillViewport(true);scroll.setBackgroundColor(0xffF2F3F6);
LinearLayout body=new LinearLayout(this);body.setOrientation(LinearLayout.VERTICAL);int side=px(round?49:22);body.setPadding(side,px(round?35:26),side,px(round?38:24));scroll.addView(body);
label(body,"蟹蟹输入",27,0xff20232B,Gravity.CENTER);
label(body,"应用内键盘组件",13,0xff8B919D,Gravity.CENTER);
TextView hint=label(body,"轻点输入,自动展开\n无需更换系统输入法",14,0xff2867D9,Gravity.CENTER);hint.setPadding(0,px(12),0,px(10));
field(body,"点击这里试写",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_FLAG_MULTI_LINE,"demo_text",1);
field(body,"私密输入",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_PASSWORD,"demo_password",2);
field(body,"数字输入",InputType.TYPE_CLASS_NUMBER|InputType.TYPE_NUMBER_FLAG_DECIMAL,"demo_number",3);
field(body,"网址输入",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_URI,"demo_url",4);
Button license=new Button(this);license.setAllCaps(false);license.setText("开源与接入说明");license.setTextSize(TypedValue.COMPLEX_UNIT_PX,px(14));license.setTextColor(0xff727987);license.setBackgroundColor(Color.TRANSPARENT);
license.setOnClickListener(view->new android.app.AlertDialog.Builder(this).setTitle("蟹蟹输入适配改版").setMessage("MIT允许商用。基于CrabKeyboard词库,由herry重写RTOS及Android应用内适配。原作者kqakqakqa、crabKun;词频jieba。\n\n键盘仅由接入它的应用使用,不更改系统默认输入法。不申请网络权限,不保存输入内容。\n\n开发者接入与源码:git2.sydxky.cn/sydxky/crab-input-rtos").setPositiveButton("知道了",null).show());body.addView(license);
body.setFocusableInTouchMode(true);body.requestFocus();setContentView(scroll);
}
private TextView label(LinearLayout body,String text,int size,int color,int gravity){TextView view=new TextView(this);view.setText(text);view.setTextSize(TypedValue.COMPLEX_UNIT_PX,px(size));view.setTextColor(color);view.setGravity(gravity);body.addView(view);return view;}
private void field(LinearLayout body,String hint,int type,String name,int id){
EditText editor=new EditText(this);editor.setId(id);editor.setSaveEnabled(false);editor.setHint(hint);editor.setContentDescription(name);editor.setInputType(type);editor.setTextSize(TypedValue.COMPLEX_UNIT_PX,px(19));editor.setTextColor(0xff20232B);editor.setImeOptions(EditorInfo.IME_ACTION_DONE);editor.setMinHeight(px(44));
// Vendor EditText backgrounds add large vertical insets on watches.
// Explicit padding keeps both empty hints and entered text fully visible.
editor.setPadding(px(12),0,px(12),0);editor.setGravity(Gravity.CENTER_VERTICAL);
editor.setIncludeFontPadding(false);editor.setSingleLine(true);
android.graphics.drawable.GradientDrawable background=new android.graphics.drawable.GradientDrawable();background.setColor(Color.WHITE);background.setCornerRadius(px(12));editor.setBackground(background);
LinearLayout.LayoutParams layout=new LinearLayout.LayoutParams(-1,px(47));layout.topMargin=px(7);body.addView(editor,layout);bindings.add(CrabKeyboard.attach(this,editor));
}
private int px(int value){return Math.round(value*scale);}
@Override protected void onDestroy(){for(CrabKeyboard.Binding binding:bindings)binding.close();bindings.clear();super.onDestroy();}
}
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="96dp" android:height="96dp" android:viewportWidth="96" android:viewportHeight="96">
<path android:fillColor="#2867D9" android:pathData="M20,6L76,6Q90,6 90,20L90,76Q90,90 76,90L20,90Q6,90 6,76L6,20Q6,6 20,6" />
<path android:fillColor="#FFFFFF" android:pathData="M25,28L71,28L71,68L25,68ZM30,34L30,41L37,41L37,34ZM44,34L44,41L51,41L51,34ZM58,34L58,41L65,41L65,34ZM30,47L30,54L37,54L37,47ZM44,47L44,54L51,54L51,47ZM58,47L58,54L65,54L65,47ZM35,60L35,63L61,63L61,60Z" android:fillType="evenOdd" />
</vector>
@@ -0,0 +1,8 @@
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item><item name="android:colorAccent">#2867D9</item>
<item name="android:windowLightStatusBar">true</item><item name="android:statusBarColor">#F2F3F6</item>
<item name="android:navigationBarColor">#ECEEF2</item><item name="android:windowActionModeOverlay">true</item>
<item name="android:windowBackground">#F2F3F6</item>
</style>
</resources>
+4
查看文件
@@ -0,0 +1,4 @@
plugins {
id 'com.android.application' version '9.2.1' apply false
id 'com.android.library' version '9.2.1' apply false
}
+11
查看文件
@@ -0,0 +1,11 @@
param([string]$ReleaseSigningProperties)
$ErrorActionPreference='Stop'
Push-Location $PSScriptRoot
try {
& python '.\scripts\build-dictionary.py'
if($LASTEXITCODE -ne 0){throw 'Dictionary build failed'}
$tasks=@(':keyboard:testDebugUnitTest',':keyboard:lintRelease',':keyboard:assembleRelease',':app:lintRelease',':host:assembleDebug')
if($ReleaseSigningProperties){$tasks+=@(':app:assembleRelease',"-PreleaseSigningProperties=$ReleaseSigningProperties")}else{$tasks+=':app:assembleDebug'}
& '.\gradlew.bat' @tasks
if($LASTEXITCODE -ne 0){throw 'Android verification/build failed'}
} finally {Pop-Location}
+5
查看文件
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
# Production source is Java. Keep Kotlin needed by the test runner in test APKs.
kotlin.stdlib.default.dependency=false
二进制
查看文件
二进制文件未显示。
+8
查看文件
@@ -0,0 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.5.1-bin.zip
networkTimeout=120000
networkRetries=3
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
第三方依赖
+12
查看文件
@@ -0,0 +1,12 @@
#!/bin/sh
APP_HOME=$(cd "${0%/*}" && pwd -P)
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ]; then
JAVACMD=$JAVA_HOME/bin/java
else
JAVACMD=java
fi
exec "$JAVACMD" -Xmx64m -Xms64m -Dorg.gradle.appname=gradlew -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
第三方依赖
+91
查看文件
@@ -0,0 +1,91 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%\bin\java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+8
查看文件
@@ -0,0 +1,8 @@
plugins { id 'com.android.application' }
android {
namespace 'com.sydxky.crabinput.host'
compileSdk 37
defaultConfig { applicationId 'com.sydxky.crabinput.host'; minSdk 27; targetSdk 35; versionCode 10200; versionName '1.2.0' }
compileOptions { sourceCompatibility JavaVersion.VERSION_11; targetCompatibility JavaVersion.VERSION_11 }
}
dependencies { implementation project(':keyboard') }
@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:label="键盘接入参考" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false">
<activity android:name=".HostActivity" android:exported="true" android:windowSoftInputMode="stateAlwaysHidden" />
</application>
</manifest>
@@ -0,0 +1,20 @@
package com.sydxky.crabinput.host;
import android.app.Activity;
import android.os.Bundle;
import android.view.Gravity;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.sydxky.crabinput.CrabKeyboard;
/** Separate application demonstrates the public library API, not a system IME. */
public final class HostActivity extends Activity {
private CrabKeyboard.Binding binding;
@Override public void onCreate(Bundle saved){
super.onCreate(saved);LinearLayout layout=new LinearLayout(this);layout.setOrientation(LinearLayout.VERTICAL);layout.setGravity(Gravity.CENTER);layout.setPadding(55,45,55,45);
TextView title=new TextView(this);title.setText("独立应用接入示例");title.setTextSize(13);layout.addView(title);
EditText editor=new EditText(this);editor.setContentDescription("fixture_editor");editor.setHint("点击输入");layout.addView(editor,new LinearLayout.LayoutParams(-1,-2));
layout.setFocusableInTouchMode(true);layout.requestFocus();setContentView(layout);binding=CrabKeyboard.attach(this,editor);
}
@Override protected void onDestroy(){if(binding!=null)binding.close();super.onDestroy();}
}
+9
查看文件
@@ -0,0 +1,9 @@
plugins { id 'com.android.library' }
android {
namespace 'com.sydxky.crabinput.keyboard'
compileSdk 37
defaultConfig { minSdk 27; consumerProguardFiles 'consumer-rules.pro' }
compileOptions { sourceCompatibility JavaVersion.VERSION_11; targetCompatibility JavaVersion.VERSION_11 }
lint { abortOnError true }
}
dependencies { testImplementation 'junit:junit:4.13.2' }
+3
查看文件
@@ -0,0 +1,3 @@
# Public integration API. No reflection is required by the keyboard engine.
-keep public class com.sydxky.crabinput.CrabKeyboard { public *; }
-keep public class com.sydxky.crabinput.CrabKeyboard$Binding { public *; }
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,52 @@
{
"keys": 104837,
"candidates": 163238,
"longest_word": 4,
"sha256": "fa01b1ab0264d8b271a32338b93a64b8670a71d57264c8586c89ae3cf3ddd8a2",
"bytes": 12644352,
"round_trip": true,
"source_files": {
"key0.dat": "795a5316089b67a7bffabf87080bd61f3720f9e0fd2f2866a092901f99b794e8",
"key1.dat": "a4bcae2b6eb30084d3cf2ac64e5d9ca71eb94e8ac1f7c8fc8b38a5d478982be2",
"key10.dat": "5731d9f3a3cad0304ecc650d74c723185c9d43f74d66d7b5622454d1fc31f99c",
"key11.dat": "fa2a4bfa64cb236a051ceaf6b9b227159dde7aaba21ae997baafac6df4332345",
"key12.dat": "7685ad03e3b5eeac404e8d6f0fc9e8524262b2c4a42ef0301d9e85c64d3db63b",
"key13.dat": "b06f6fc0e2769a8f6e9491a0b97148f8c8d211ba8fe8ed122e1dbffca0eb7399",
"key14.dat": "5d21973e2c1d833e93bfe51c42064b26743f6012e35c73ffcd2b5046cca3eeff",
"key15.dat": "62c3324ccc143cd4e9ee533b0117ee06fc893d58c5d2ed0353da4dde33f5eab1",
"key16.dat": "8c2c0fc7568cf6b674e8630449eb989c7ebdf34f34d0b2041a59748bd20c9638",
"key17.dat": "f62c9c886ca4ee387a780a8c1e24116d73dedfda228de3614978c22f994ef7ec",
"key18.dat": "fa7fda7b3b452908623383a6285aa15b265a41d48de58524fe0f0879b8aad259",
"key19.dat": "00674fb37b56b5973cf0a7ee38b8258652949e4c02782ff6651d2aa63b9c62fc",
"key2.dat": "45e860c89d1362de6007e46a6aa84a0d2fcd9682924de6e5f111cd536e18bfd1",
"key20.dat": "1715a74e0bd00bf89406806682d865a53782d4cd9c4aee5050e547e8a84ffad6",
"key3.dat": "00b464b6ee7ecd7e4406150eecf6c512e22611c6505a5045834cd5c3b7d9068d",
"key4.dat": "a5c5719d09f931efc44b629fe92fd3faea0cc8c5a6ec28d0a9c9b9cdb0eb5ad9",
"key5.dat": "3d5e43374441150af704b9dae9a6d62ac66406306b37709f12119fe44153e353",
"key6.dat": "586c47ee8542f8bf59d7b4bf71fb1715a1bf3e7452d91068edcaf8b566e81e52",
"key7.dat": "4525656f4d9fcd85bff07b876af75eb84f4f711a55a63c258c491d22dfeedff5",
"key8.dat": "8e88d27fbc09fc3b133cbafd3b8e2f1b2269950cfcae12c23c1e152013f63f47",
"key9.dat": "c741aefb551b686a9ade67d3a16a5230d74134af6c3d0faf08557e19698d5825",
"value1.dat": "81c1798a0dda463cacae6ac5285ed9ad0cd0730dde167c18ee3500f1e1d8d8f2",
"value10.dat": "9c4fded85bb7467304df917d510d987a824785058a9f8bb9c9095571b49fbf0d",
"value11.dat": "3be277f87112c7bbc7242882b4ca92ff6e9d38d7be1b51b7899545a5c13d1a19",
"value12.dat": "789c47185b5aaa93293239b356a6a4928d3d29daaf9a4d18f3c94f392f861613",
"value13.dat": "b7366771afebe2ddc466eb9dc8dd840b1e7a8a8214354e2930d4fa4346f27dc6",
"value14.dat": "9c638ef5d554738087505b158482e5b0caba59a36df65b9d0f3e5eee78f91588",
"value15.dat": "846ea5776c9e11f44762cc032d690435464a312e82813530b01cb6001bd0a2f6",
"value16.dat": "b86c42aaf4a818b4eaccd2663b6fb01f3ef3b1dc8482f20636cbde1a95ca52da",
"value17.dat": "2f809c59a318d7d8d8605985092517902b6c627a40936af856785ed596c1133e",
"value18.dat": "d64613d9bc90f503b964984939de0dc881c3a0feda2684bade0c572555c2099d",
"value19.dat": "ecfb7ba56450c671fc941d56556a42d508569e341c5dd2581b18a3d7d27ab55c",
"value2.dat": "d66703e7ac7ab6c2cd3b3c8109e656e1f78ace3e2bb49123ce3a2b3da4ca04d0",
"value20.dat": "896757081c224c72586dfa5babb4300c93639a27ba9a70eb34b9dfd5c23735f2",
"value3.dat": "19c47c2f8b8483946db81572368b3f3b2aa61c5a03953351e327f3f83cb31275",
"value4.dat": "1be64f23183fca36920ae3f002d541febaa1b26b5ca3018ee53ffdd05546d56d",
"value5.dat": "7b8a109e705537784a0a8075cc4fe3d1de8288a6b6b0240627685c81ed9695a9",
"value6.dat": "5b4028a017b228357446f799cd29b28dd5a24d63340f8b48ce858c75c64029f1",
"value7.dat": "b9f6e109ba5f1b5bca758613cb5cccfecf358915ce6415525c333b19515d6428",
"value8.dat": "3d1622d4717680b31a6e037c88e7f046fecfe2339e521272e1d1eae0a7094606",
"value9.dat": "1c0caab370f1f14048ab6dd2da0011bedcddfed0ed0378b5c98171b6b22caf35"
},
"jieba_sha256": "7197c3211ddd98962b036cdf40324d1ea2bfaa12bd028e68faa70111a88e12a8"
}
@@ -0,0 +1,23 @@
MIT License
Copyright (c) 2026 sydxky
Copyright (c) 2026 kqakqakqa
Copyright (c) 2026 crabKun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,17 @@
# 蟹蟹输入 Android手表适配改版
本项目由sydxky基于[CrabKeyboard / 蟹蟹输入](https://github.com/crabPangXie/crabKeyboard)的MIT词库重新实现Android应用内键盘组件、圆屏布局、九宫/14键消歧及候选分页。不是上游原版,也不表示上游作者或设备厂商对本改版作出背书。
上游固定提交:`93d36b5002dee80a3d253b1ec7ec9e29800a104c`。保留原作者kqakqakqa、crabKun的版权声明和MIT许可证;原始词库位于同仓库RTOS目录。Android自研适配部分以[MIT许可证](./LICENSE)发布,**允许商用、修改、再分发和闭源集成**,须保留适用版权及许可全文。
第三方内容继续按各自许可证使用:
| 内容 | 作者/项目 | 许可与位置 |
| --- | --- | --- |
| CrabKeyboard V5词库 | kqakqakqa、crabKun | MIT,`keyboard/src/main/assets/licenses/crabKeyboard-LICENSE.txt` |
| 本地候选排序词频 | jieba 0.42.1 / Sun Junyi | MIT,`keyboard/src/main/assets/licenses/jieba-LICENSE.txt` |
| Gradle wrapper | Gradle | Apache-2.0,`keyboard/src/main/assets/licenses/Gradle-LICENSE.txt` |
14键参考公开演示中的相邻字母分组,自行实现离线消歧;不包含华为内部模型或固件代码。Android SDK、JDK和构建工具不随源码分发,按各自许可使用。AndroidX及JUnit仅用于本地测试,不打入正式APK。
公开仓库:<https://git2.sydxky.cn/sydxky/crab-input-rtos>。匿名问题与建议:<https://git2.sydxky.cn/feedback/sydxky/crab-input-rtos>。
@@ -0,0 +1,422 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
==============================================================================
Licenses for included components:
------------------------------------------------------------------------------
Eclipse Public License 1.0
https://opensource.org/licenses/EPL-1.0
junit:junit
org.sonatype.aether:aether-api
org.sonatype.aether:aether-connector-wagon
org.sonatype.aether:aether-impl
org.sonatype.aether:aether-spi
org.sonatype.aether:aether-util
------------------------------------------------------------------------------
3-Clause BSD
https://opensource.org/licenses/BSD-3-Clause
com.google.code.findbugs:jsr305
org.hamcrest:hamcrest
BSD 3-Clause License
Copyright (c) 2000-2024, www.hamcrest.org
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
com.esotericsoftware.kryo:kryo
com.esotericsoftware.minlog:minlog
Copyright (c) 2008-2018, Nathan Sweet All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
Neither the name of Esoteric Software nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
org.ow2.asm:asm
org.ow2.asm:asm-analysis
org.ow2.asm:asm-commons
org.ow2.asm:asm-tree
org.ow2.asm:asm-util
ASM: a very small and fast Java bytecode manipulation framework
Copyright (c) 2000-2011 INRIA, France Telecom
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holders nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
THE POSSIBILITY OF SUCH DAMAGE.
------------------------------------------------------------------------------
MIT
com.googlecode.plist:dd-plist
dd-plist - An open source library to parse and generate property lists
Copyright (C) 2016 Daniel Dreibrodt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
org.bouncycastle:bcpg-jdk15on
org.bouncycastle:bcprov-jdk15on
Copyright (c) 2000 - 2019 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
org.slf4j:jcl-over-slf4j
org.slf4j:jul-to-slf4j
org.slf4j:log4j-over-slf4j
org.slf4j:slf4j-api
Copyright (c) 2004-2022 QOS.ch Sarl (Switzerland)
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
------------------------------------------------------------------------------
CDDL
https://opensource.org/licenses/CDDL-1.0
com.sun.xml.bind:jaxb-impl
------------------------------------------------------------------------------
LGPL 2.1
https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html
org.samba.jcifs:jcifs
org.jetbrains.intellij.deps:trove4j
------------------------------------------------------------------------------
License for the GNU Trove library included by the Kotlin embeddable compiler
------------------------------------------------------------------------------
The source code for GNU Trove is licensed under the Lesser GNU Public License (LGPL).
Copyright (c) 2001, Eric D. Friedman All Rights Reserved. This library is free software; you can redistribute it and/or modify it under
the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or
(at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU Lesser General Public License along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
Two classes (HashFunctions and PrimeFinder) included in Trove are licensed under the following terms:
Copyright (c) 1999 CERN - European Organization for Nuclear Research. Permission to use, copy, modify, distribute and sell this software
and its documentation for any purpose is hereby granted without fee, provided that the above copyright notice appear in all copies and
that both that copyright notice and this permission notice appear in supporting documentation. CERN makes no representations about the
suitability of this software for any purpose. It is provided "as is" without expressed or implied warranty.
The source code of modified GNU Trove library is available at
https://github.com/JetBrains/intellij-deps-trove4j (with trove4j_changes.txt describing the changes)
------------------------------------------------------------------------------
Eclipse Distribution License 1.0
https://www.eclipse.org/org/documents/edl-v10.php
org.eclipse.jgit:org.eclipse.jgit
------------------------------------------------------------------------------
BSD-style
com.jcraft:jzlib
com.github.mwiede:jsch
Copyright (c) 2002-2015 Atsuhiko Yamanaka, JCraft,Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the distribution.
3. The names of the authors may not be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JCRAFT,
INC. OR ANY CONTRIBUTORS TO THIS SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
------------------------------------------------------------------------------
Eclipse Public License 2.0
https://www.eclipse.org/legal/epl-2.0/
org.junit.platform:junit-platform-launcher
------------------------------------------------------------------------------
Mozilla Public License 2.0
https://www.mozilla.org/en-US/MPL/2.0/
org.mozilla:rhino
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 kqakqakqa
Copyright (c) 2026 crabKun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2013 Sun Junyi
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,7 @@
package com.sydxky.crabinput;
public final class Candidate {
public final String word;
public final int consume;
public Candidate(String word, int consume) { this.word=word; this.consume=consume; }
}
@@ -0,0 +1,37 @@
package com.sydxky.crabinput;
import java.util.ArrayList;
import java.util.List;
/** 4 columns x 4 row units; long phrases get full width and extra row units. */
public final class CandidatePager {
public static final class Cell {
public final Candidate candidate;
public final int span;
Cell(Candidate candidate,int span) { this.candidate=candidate; this.span=span; }
}
public static final class Row {
public final List<Cell> cells=new ArrayList<>();
public final int units;
public int slots;
Row(int units) { this.units=units; }
}
public static List<List<Row>> pages(List<Candidate> candidates) {
List<List<Row>> pages=new ArrayList<>();List<Row> page=new ArrayList<>();
Row row=null;int used=0;
for(Candidate candidate:candidates) {
int length=candidate.word.codePointCount(0,candidate.word.length());
int span=length==1?1:length<=4?2:4;
int units=span==4?Math.max(1,(length+11)/12):1;
if(row!=null&&(row.slots+span>4||row.units!=units)) { page.add(row);used+=row.units;row=null; }
if(used+units>4) { if(!page.isEmpty()) pages.add(page);page=new ArrayList<>();used=0; }
if(row==null) row=new Row(units);
row.cells.add(new Cell(candidate,span));row.slots+=span;
if(row.slots==4) { page.add(row);used+=row.units;row=null; }
}
if(row!=null)page.add(row);
if(!page.isEmpty())pages.add(page);
return pages;
}
private CandidatePager() {}
}
@@ -0,0 +1,61 @@
package com.sydxky.crabinput;
import android.app.Activity;
import android.app.Application;
import android.app.Dialog;
import android.os.Bundle;
import android.text.Editable;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
/** Public AAR entry point. Works inside the host app; no system IME settings. */
public final class CrabKeyboard {
public static Binding attach(Activity activity,EditText editor){return new Binding(activity,editor);}
public static final class Binding implements AutoCloseable,Application.ActivityLifecycleCallbacks {
private final Activity activity;
private final EditText editor;
private final boolean originalShowSoftInput;
private Dialog dialog;
private boolean closed;
private Binding(Activity activity,EditText editor){
if(android.os.Looper.myLooper()!=android.os.Looper.getMainLooper())throw new IllegalStateException("Attach on the UI thread");
this.activity=activity;this.editor=editor;originalShowSoftInput=editor.getShowSoftInputOnFocus();editor.setShowSoftInputOnFocus(false);
editor.setOnFocusChangeListener((view,focus)->{if(focus)show();});editor.setOnClickListener(view->show());activity.getApplication().registerActivityLifecycleCallbacks(this);
}
public void show(){
if(closed||dialog!=null||activity.isFinishing()||activity.isDestroyed())return;
if(editor.getSelectionStart()<0)editor.setSelection(editor.length());
EditorInfo info=new EditorInfo();info.inputType=editor.getInputType();info.imeOptions=editor.getImeOptions();
BaseInputConnection connection=new BaseInputConnection(editor,true){
@Override public Editable getEditable(){return editor.getText();}
@Override public boolean sendKeyEvent(KeyEvent event){return editor.dispatchKeyEvent(event);}
@Override public boolean performEditorAction(int actionCode){editor.onEditorAction(actionCode);return true;}
};
Dialog session=new Dialog(activity,android.R.style.Theme_Material_Light_NoActionBar);dialog=session;
KeyboardController controller=new KeyboardController(activity,connection,info,session::dismiss);
session.setOwnerActivity(activity);session.setContentView(controller.surface,new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,controller.surface.desiredHeight()));
Window window=session.getWindow();
if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);window.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);window.setGravity(Gravity.BOTTOM);
if(activity.getResources().getConfiguration().isScreenRound()){window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN|View.SYSTEM_UI_FLAG_HIDE_NAVIGATION|View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);}}
session.setOnDismissListener(ignored->{controller.close();connection.closeConnection();if(dialog==session)dialog=null;});session.show();
if(window!=null)window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT,activity.getResources().getConfiguration().isScreenRound()?ViewGroup.LayoutParams.MATCH_PARENT:ViewGroup.LayoutParams.WRAP_CONTENT);
}
public void dismiss(){if(dialog!=null)dialog.dismiss();}
@Override public void close(){if(closed)return;closed=true;dismiss();editor.setOnFocusChangeListener(null);editor.setOnClickListener(null);editor.setShowSoftInputOnFocus(originalShowSoftInput);activity.getApplication().unregisterActivityLifecycleCallbacks(this);}
@Override public void onActivityStopped(Activity value){if(value==activity)dismiss();}
@Override public void onActivityDestroyed(Activity value){if(value==activity)close();}
@Override public void onActivityCreated(Activity value,Bundle state){}
@Override public void onActivityStarted(Activity value){}
@Override public void onActivityResumed(Activity value){}
@Override public void onActivityPaused(Activity value){}
@Override public void onActivitySaveInstanceState(Activity value,Bundle state){}
}
private CrabKeyboard(){}
}
@@ -0,0 +1,107 @@
package com.sydxky.crabinput;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.text.InputType;
import android.view.KeyEvent;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
/** One transient in-app editing session. It never selects a system IME. */
final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable {
private final Handler main=new Handler(Looper.getMainLooper());
private final ExecutorService worker=Executors.newSingleThreadExecutor();
final KeyboardState state=new KeyboardState();
final KeyboardSurface surface;
private final Lexicon lexicon;
private final InputConnection input;
private final EditorInfo info;
private final Runnable dismiss;
private final Context context;
private Future<?> pending;
private int revision;
private boolean active=true,repeat,closed;
private final Runnable lookup=()->query(revision,state.composition,state.mode);
private final Runnable deleteAgain=new Runnable(){@Override public void run(){if(repeat&&active){delete();main.postDelayed(this,115);}}};
@android.annotation.SuppressLint("WearPasswordInput") // Detect host password editors, no account login.
KeyboardController(Context context,InputConnection input,EditorInfo info,Runnable dismiss){
this.context=context;this.input=input;this.info=info;this.dismiss=dismiss;lexicon=new Lexicon(context);
int kind=info.inputType&InputType.TYPE_MASK_CLASS,variation=info.inputType&InputType.TYPE_MASK_VARIATION;
state.secret=kind==InputType.TYPE_CLASS_TEXT&&(variation==InputType.TYPE_TEXT_VARIATION_PASSWORD||variation==InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD||variation==InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD)||kind==InputType.TYPE_CLASS_NUMBER&&variation==InputType.TYPE_NUMBER_VARIATION_PASSWORD;
state.mode=kind==InputType.TYPE_CLASS_NUMBER||kind==InputType.TYPE_CLASS_PHONE?"num":state.secret||variation==InputType.TYPE_TEXT_VARIATION_URI||variation==InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS?"en":"t9";
surface=new KeyboardSurface(context,state,this);refresh();
}
private void invalidateLookup(){revision++;main.removeCallbacks(lookup);if(pending!=null){pending.cancel(true);pending=null;}state.busy=false;}
private void refresh(){
if(active&&!state.secret){CharSequence text=input.getTextBeforeCursor(24,0);state.preview=text==null?"":text.toString();}else state.preview="";
if(!repeat)surface.refresh();
}
private void message(String text){state.message=text;android.widget.Toast.makeText(context,text,android.widget.Toast.LENGTH_SHORT).show();refresh();}
private boolean chinese(){return state.mode.equals("py")||state.mode.equals("t9")||state.mode.equals("14");}
private void schedule(){
invalidateLookup();state.page=0;state.candidates=new ArrayList<>();state.pages=new ArrayList<>();
if(!state.composition.isEmpty()&&active){state.busy=true;main.postDelayed(lookup,40);}refresh();
}
private void query(int owner,String composition,String mode){
pending=worker.submit(()->{
try {List<Candidate> result=lexicon.lookup(composition,mode);main.post(()->{if(!active||revision!=owner)return;state.candidates=result;state.pages=CandidatePager.pages(result);state.busy=false;state.message=result.isEmpty()?"暂无匹配,请退格修改":"离线输入";refresh();});}
catch(Exception ignored){main.post(()->{if(active&&revision==owner){state.busy=false;state.message="词库暂不可用,可切换英文";refresh();}});}
});
}
private void commit(String text){if(active)input.commitText(text,1);refresh();}
@Override public void choose(Candidate item){
if(!active||state.busy||state.composition.isEmpty()||!state.candidates.contains(item))return;
String remaining=state.composition.substring(Math.min(item.consume,state.composition.length()));
invalidateLookup();commit(item.word);state.composition=remaining;state.expanded=false;schedule();
}
private void delete(){
if(!active)return;
if(!state.composition.isEmpty()){state.composition=state.composition.substring(0,state.composition.length()-1);schedule();return;}
CharSequence selection=input.getSelectedText(0);if(selection!=null&&selection.length()>0)input.commitText("",1);else input.deleteSurroundingTextInCodePoints(1,0);refresh();
}
@Override public void repeatDelete(boolean start){boolean previous=repeat;main.removeCallbacks(deleteAgain);repeat=start&&active;if(repeat){delete();main.postDelayed(deleteAgain,450);}else if(previous)refresh();}
@Override public void action(String value){
if(!active)return;
if(value.startsWith("key:")){
String key=value.substring(4);
if(chinese()){if(state.composition.length()<32){state.composition+=key;schedule();}else message("每段最多32位");}else commit(key);return;
}
if(value.startsWith("mode:")){
String mode=value.substring(5);if(!state.composition.isEmpty()&&!mode.equals(state.mode)){message("请先选择候选词");return;}
if(mode.equals("en")&&state.mode.equals("en"))state.upper=!state.upper;
if(mode.equals("sym"))state.symbolPage=state.mode.equals("sym")?(state.symbolPage+1)%3:0;
state.mode=mode;state.expanded=false;state.tools=false;schedule();return;
}
switch(value){
case "delete":delete();return;
case "space":if(!state.composition.isEmpty()){if(!state.busy&&!state.candidates.isEmpty())choose(state.candidates.get(0));}else commit(" ");return;
case "expand":if(!state.candidates.isEmpty()){state.expanded=true;state.tools=false;state.page=0;}break;
case "previous":state.page=Math.max(0,state.page-1);break;
case "next":state.page=Math.min(Math.max(0,state.pages.size()-1),state.page+1);break;
case "back":state.expanded=false;state.tools=false;break;
case "tools":state.expanded=false;state.tools=true;break;
case "clear-composition":state.composition="";schedule();return;
case "hide":dismiss.run();return;
case "left":case "right":if(state.composition.isEmpty()){int key=value.equals("left")?KeyEvent.KEYCODE_DPAD_LEFT:KeyEvent.KEYCODE_DPAD_RIGHT;input.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN,key));input.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP,key));}break;
case "raw":if(state.mode.equals("py")&&!state.composition.isEmpty()){String text=state.composition;state.composition="";commit(text);schedule();}break;
case "newline":if(!state.secret&&(info.inputType&InputType.TYPE_TEXT_FLAG_MULTI_LINE)!=0)commit("\n");break;
case "done":
if(!state.composition.isEmpty()){message("请先选择候选词");state.expanded=false;break;}
int editorAction=info.imeOptions&EditorInfo.IME_MASK_ACTION;
if(editorAction!=EditorInfo.IME_ACTION_NONE&&editorAction!=EditorInfo.IME_ACTION_UNSPECIFIED)input.performEditorAction(editorAction);
dismiss.run();return;
default:break;
}
refresh();
}
@Override public void close(){
if(closed)return;closed=true;active=false;invalidateLookup();repeatDelete(false);main.removeCallbacksAndMessages(null);
state.composition="";state.preview="";state.candidates=new ArrayList<>();state.pages=new ArrayList<>();worker.execute(lexicon::close);worker.shutdown();
}
}
@@ -0,0 +1,12 @@
package com.sydxky.crabinput;
import java.util.ArrayList;
import java.util.List;
final class KeyboardState {
String mode="t9",composition="",preview="",message="离线输入";
boolean expanded,tools,upper,secret,busy;
int page,symbolPage;
List<Candidate> candidates=new ArrayList<>();
List<List<CandidatePager.Row>> pages=new ArrayList<>();
}
@@ -0,0 +1,175 @@
package com.sydxky.crabinput;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.RippleDrawable;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/** Native accessible buttons laid out within the actual round screen chord. */
@android.annotation.SuppressLint("ViewConstructor") // Created by the IME, never inflated from XML.
final class KeyboardSurface extends ViewGroup {
interface Actions { void action(String value);void choose(Candidate item);void repeatDelete(boolean start); }
private static final int BLUE=Color.rgb(40,103,217),INK=Color.rgb(32,35,43),MUTED=Color.rgb(132,139,151),BG=Color.rgb(236,238,242);
private final KeyboardState state;
private final Actions actions;
private final boolean round;
private final List<Placement> placements=new ArrayList<>();
private int builtWidth,builtHeight;
private float sx=1,sy=1;
private static final class Placement {
final View view;final Rect bounds;
Placement(View view,Rect bounds){this.view=view;this.bounds=bounds;}
}
KeyboardSurface(Context context,KeyboardState state,Actions actions) {
super(context);this.state=state;this.actions=actions;round=getResources().getConfiguration().isScreenRound();
setBackgroundColor(BG);setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);setContentDescription("蟹蟹输入键盘");
}
int desiredHeight() {
android.util.DisplayMetrics dm=getResources().getDisplayMetrics();
boolean watch=round||(dm.heightPixels/(float)dm.widthPixels<1.35f);
return watch?dm.heightPixels:Math.min(dm.widthPixels,Math.round(dm.heightPixels*.46f));
}
void refresh() { if(builtWidth>0)build(builtWidth,builtHeight);requestLayout();invalidate(); }
private Rect bounds(float x,float y,float width,float height) {
return new Rect(Math.round(x*sx),Math.round(y*sy),Math.round((x+width)*sx),Math.round((y+height)*sy));
}
private float inset(float y,float height) {
if(!round)return 8;
float cy=builtHeight/2f,r=builtWidth/2f-7*sx;
float distance=Math.max(Math.abs(y*sy-cy),Math.abs((y+height)*sy-cy));
float half=(float)Math.sqrt(Math.max(0,r*r-distance*distance));
return Math.max(8,(builtWidth/2f-half)/sx+2);
}
private void place(View view,float x,float y,float width,float height) {
addView(view);placements.add(new Placement(view,bounds(x,y,width,height)));
}
private TextView label(String text,float font,int color) {
TextView view=new TextView(getContext());view.setText(text);view.setTextColor(color);view.setGravity(Gravity.CENTER);
view.setTextSize(TypedValue.COMPLEX_UNIT_PX,font*Math.min(sx,sy));view.setIncludeFontPadding(false);return view;
}
private Button button(String text,String description,float font,boolean primary,Runnable action) {
Button button=new Button(getContext());button.setText(text);button.setContentDescription(description);button.setAllCaps(false);
button.setGravity(Gravity.CENTER);button.setIncludeFontPadding(false);button.setMinHeight(0);button.setMinimumHeight(0);button.setMinWidth(0);button.setMinimumWidth(0);
button.setPadding(Math.round(3*sx),0,Math.round(3*sx),0);button.setTextSize(TypedValue.COMPLEX_UNIT_PX,font*Math.min(sx,sy));
button.setTextColor(primary?Color.WHITE:INK);button.setStateListAnimator(null);
GradientDrawable shape=new GradientDrawable();shape.setColor(primary?BLUE:Color.WHITE);shape.setCornerRadius(10*Math.min(sx,sy));
button.setBackground(new RippleDrawable(ColorStateList.valueOf(0x332867D9),shape,null));button.setOnClickListener(v->action.run());return button;
}
// Native Button click remains available to accessibility; touch deletion starts on down.
@android.annotation.SuppressLint("ClickableViewAccessibility")
private void action(String text,String value,float x,float y,float width,float height,float font,boolean primary) {
String name=value.equals("delete")?"删除":value.equals("done")?"完成":value.equals("hide")?"收起键盘":value.equals("expand")?"更多候选":value.equals("back")?"返回键盘":value.equals("previous")?"上一页":value.equals("next")?"下一页":text;
Button button=button(text,name,font,primary,()->actions.action(value));
if(value.equals("delete"))button.setOnTouchListener((v,event)->{
if(event.getActionMasked()==MotionEvent.ACTION_DOWN)actions.repeatDelete(true);
if(event.getActionMasked()==MotionEvent.ACTION_UP||event.getActionMasked()==MotionEvent.ACTION_CANCEL)actions.repeatDelete(false);
return true;
});
place(button,x,y,width,height);
}
private void row(String[] captions,String[] values,float y,float height,float font) {
float inset=inset(y,height),width=410-2*inset,cell=width/captions.length;
for(int i=0;i<captions.length;i++)action(captions[i],values[i],inset+i*cell+2,y,cell-4,height,font,false);
}
private void build(int width,int height) {
builtWidth=width;builtHeight=height;sx=width/410f;sy=height/410f;removeAllViews();placements.clear();
float top=round?28:7;
float headerInset=inset(top,23);
action("⌄","hide",headerInset,top,32,23,23,false);
String title=state.expanded?"完整候选":state.tools?"编辑工具":"蟹蟹输入";
place(label(title,14,MUTED),headerInset+34,top,410-2*headerInset-68,23);
action("编辑","tools",410-headerInset-32,top,32,23,10,false);
float previewY=top+29;
String preview=state.secret?"私密输入":state.preview.isEmpty()?"开始输入…":state.preview;
TextView previewView=label(preview,21,INK);previewView.setMaxLines(1);previewView.setEllipsize(android.text.TextUtils.TruncateAt.START);
float pi=inset(previewY,30);place(previewView,pi,previewY,410-2*pi,30);
if(state.expanded) { buildCandidates(previewY+38);return; }
if(state.tools) { buildTools(previewY+40);return; }
float compositionY=previewY+32;
String composition=state.composition;
if(state.mode.equals("14")) { StringBuilder display=new StringBuilder();for(char ch:composition.substring(Math.max(0,composition.length()-6)).toCharArray())display.append(Lexicon.GROUP14["0123456789abcd".indexOf(ch)].toUpperCase(Locale.ROOT)).append(' ');composition=display.toString(); }
TextView compose=label(composition.isEmpty()?modeName():composition,15,BLUE);compose.setMaxLines(1);
float ci=inset(compositionY,23);place(compose,ci,compositionY,410-2*ci,23);
float candidateY=compositionY+26;
if(isChinese()) {
float left=inset(candidateY,32),available=410-2*left;
int count=Math.min(3,state.candidates.size());float cell=(available-37)/Math.max(1,count);
for(int i=0;i<count;i++){Candidate item=state.candidates.get(i);Button b=button(item.word,item.word,22,false,()->actions.choose(item));b.setMaxLines(1);b.setAutoSizeTextTypeUniformWithConfiguration(10,22,1,TypedValue.COMPLEX_UNIT_PX);place(b,left+i*cell+2,candidateY,cell-4,32);}
if(count>0)action("⌄","expand",410-left-35,candidateY,33,32,24,false);
else place(label(state.busy?"正在查词…":state.message,12,MUTED),left,candidateY,available,32);
}
float keyY=candidateY+38;
String[][] captions,values;
switch(state.mode) {
case "t9":captions=new String[][]{{"标点","ABC","DEF"},{"GHI","JKL","MNO"},{"PQRS","TUV","WXYZ"}};values=new String[][]{{"mode:sym","key:2","key:3"},{"key:4","key:5","key:6"},{"key:7","key:8","key:9"}};break;
case "14":captions=new String[][]{{"QW","ER","TY","UI","OP"},{"AS","DF","GH","JK","L"},{"ZX","CV","BN","M"}};values=new String[][]{{"key:0","key:1","key:2","key:3","key:4"},{"key:5","key:6","key:7","key:8","key:9"},{"key:a","key:b","key:c","key:d"}};break;
case "num":captions=new String[][]{{"1","2","3"},{"4","5","6"},{"7","8","9"},{".","0","-"}};values=values(captions);break;
case "sym":String[] symbols={",。?!:;","()【】《》","、…·“”「", "!\"#$%&","'()*+,","-./:;<", "=>?@[\\","]^_`{|","}~€£¥°"};captions=new String[][]{split(symbols[state.symbolPage*3]),split(symbols[state.symbolPage*3+1]),split(symbols[state.symbolPage*3+2])};values=values(captions);break;
default:captions=new String[][]{split("qwertyuiop"),split("asdfghjkl"),split("zxcvbnm")};if(state.upper&&state.mode.equals("en"))for(String[] line:captions)for(int i=0;i<line.length;i++)line[i]=line[i].toUpperCase(Locale.ROOT);values=values(captions);
}
float keyHeight=captions.length==4?36:49;
// Hide an unused suggestion strip in direct entry modes to enlarge the keys.
if(!isChinese()){keyY-=27;keyHeight=captions.length==4?43:58;}
for(int i=0;i<captions.length;i++)row(captions[i],values[i],keyY+i*(keyHeight+4),keyHeight,captions[i].length>=7?21:24);
float modeY=keyY+captions.length*(keyHeight+4)+3;
row(new String[]{"九宫","26键","14键",state.upper?"ABC":"abc","123","符号"},new String[]{"mode:t9","mode:py","mode:14","mode:en","mode:num","mode:sym"},modeY,27,13);
bottom(modeY+34,false);
}
private void buildCandidates(float y) {
int count=state.pages.size();float safe=inset(y,18);
place(label(state.candidates.size()+" 项 · "+(state.page+1)+" / "+Math.max(1,count),12,MUTED),safe,y,410-2*safe,18);
float gridY=y+25;int unit=43;
if(count>0)for(CandidatePager.Row row:state.pages.get(state.page)) {
float rowHeight=row.units*unit-4,left=inset(gridY,rowHeight),cell=(410-2*left)/4;int slot=0;
for(CandidatePager.Cell entry:row.cells) {
Candidate item=entry.candidate;Button button=button(item.word,item.word,entry.span==1?25:entry.span==2?23:20,false,()->actions.choose(item));
button.setMaxLines(row.units*2);button.setEllipsize(null);
place(button,left+slot*cell+2,gridY,entry.span*cell-4,rowHeight);slot+=entry.span;
}
gridY+=row.units*unit;
}
float pagerY=y+25+4*unit+3;
row(new String[]{"‹","›"},new String[]{"previous","next"},pagerY,29,27);
bottom(pagerY+36,true);
}
private void buildTools(float y) {
row(new String[]{"← 光标","光标 →"},new String[]{"left","right"},y+7,37,18);
row(new String[]{"换行","拼音上屏"},new String[]{"newline","raw"},y+53,37,18);
row(new String[]{"清除拼音","空格"},new String[]{"clear-composition","space"},y+99,37,18);
place(label(state.secret?"私密输入 · 不学习、不保存":"完全离线 · 不采集输入内容",12,MUTED),45,y+150,320,26);
bottom(y+190,true);
}
private void bottom(float y,boolean back) {
float left=inset(y,34),cell=(410-2*left)/3;
action(back?"键盘":"空格",back?"back":"space",left+2,y,cell-4,34,17,false);
action("⌫","delete",left+cell+2,y,cell-4,34,25,false);
action("✓","done",left+2*cell+2,y,cell-4,34,23,true);
// Preview taps open cursor tools without taking an extra keyboard key.
for(Placement placement:placements)if(placement.view instanceof TextView&&!(placement.view instanceof Button)&&placement.view.getContentDescription()==null){if(((TextView)placement.view).getText().toString().equals(state.secret?"私密输入":state.preview.isEmpty()?"开始输入…":state.preview)){placement.view.setOnClickListener(v->actions.action("tools"));placement.view.setContentDescription("编辑光标");}}
}
private boolean isChinese(){return state.mode.equals("py")||state.mode.equals("t9")||state.mode.equals("14");}
private String modeName(){return state.mode.equals("py")?"26键全拼 · v 代表 ü":state.mode.equals("t9")?"九宫拼音":state.mode.equals("14")?"14键拼音":state.mode.equals("en")?"英文":state.mode.equals("sym")?"符号 · "+(state.symbolPage+1)+"/3 · 再点符号翻页":"数字";}
private static String[] split(String text){String[] result=new String[text.length()];for(int i=0;i<text.length();i++)result[i]=text.substring(i,i+1);return result;}
private static String[][] values(String[][] captions){String[][] result=new String[captions.length][];for(int i=0;i<captions.length;i++){result[i]=new String[captions[i].length];for(int j=0;j<result[i].length;j++)result[i][j]="key:"+captions[i][j];}return result;}
@Override protected void onMeasure(int widthSpec,int heightSpec) {
int width=MeasureSpec.getSize(widthSpec),height=resolveSize(desiredHeight(),heightSpec);
if(width!=builtWidth||height!=builtHeight)build(width,height);
for(Placement item:placements)item.view.measure(MeasureSpec.makeMeasureSpec(item.bounds.width(),MeasureSpec.EXACTLY),MeasureSpec.makeMeasureSpec(item.bounds.height(),MeasureSpec.EXACTLY));
setMeasuredDimension(width,height);
}
@Override protected void onLayout(boolean changed,int l,int t,int r,int b){for(Placement item:placements)item.view.layout(item.bounds.left,item.bounds.top,item.bounds.right,item.bounds.bottom);}
@Override public boolean dispatchTouchEvent(MotionEvent event){if(event.getActionMasked()==MotionEvent.ACTION_UP||event.getActionMasked()==MotionEvent.ACTION_CANCEL)actions.repeatDelete(false);return super.dispatchTouchEvent(event);}
@Override protected void onDetachedFromWindow(){actions.repeatDelete(false);super.onDetachedFromWindow();}
}
@@ -0,0 +1,61 @@
package com.sydxky.crabinput;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/** Full upstream dictionary, offline. Called only on the service's single worker. */
public final class Lexicon implements AutoCloseable {
public static final String[] GROUP14={"qw","er","ty","ui","op","as","df","gh","jk","l","zx","cv","bn","m"};
public static final String[] GROUP9={"abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};
private final Context context;
private SQLiteDatabase db;
public Lexicon(Context context) { this.context=context.getApplicationContext(); }
public static String encode(String text,String mode) {
String[] groups=mode.equals("14")?GROUP14:GROUP9;String tokens=mode.equals("14")?"0123456789abcd":"23456789";
StringBuilder result=new StringBuilder();
for(char ch:text.toLowerCase(java.util.Locale.ROOT).toCharArray()) {
int index=-1;for(int i=0;i<groups.length;i++)if(groups[i].indexOf(ch)>=0){index=i;break;}
if(index<0)return "";result.append(tokens.charAt(index));
}
return result.toString();
}
private void open() throws Exception {
if(db!=null)return;
File directory=context.getNoBackupFilesDir();File target=new File(directory,"crab-v5-1.db");
if(!target.isFile()) {
File temporary=new File(directory,"crab-v5-1.tmp");
try(InputStream input=context.getAssets().open("dictionary.db");FileOutputStream output=new FileOutputStream(temporary)) {
byte[] buffer=new byte[32768];int length;while((length=input.read(buffer))!=-1)output.write(buffer,0,length);output.getFD().sync();
}
if(!temporary.renameTo(target))throw new java.io.IOException("Dictionary installation failed");
}
db=SQLiteDatabase.openDatabase(target.getPath(),null,SQLiteDatabase.OPEN_READONLY|SQLiteDatabase.NO_LOCALIZED_COLLATORS);
}
public List<Candidate> lookup(String input,String mode) throws Exception {
List<Candidate> result=new ArrayList<>();
String pattern=mode.equals("14")?"[0-9a-d]{1,32}":mode.equals("t9")?"[2-9]{1,32}":"[a-z]{1,32}";
if(!input.matches(pattern))return result;open();
String column=mode.equals("14")?"code14":mode.equals("t9")?"code9":"pinyin";
StringBuilder marks=new StringBuilder();String[] prefixes=new String[input.length()];
for(int i=0;i<input.length();i++){if(i>0)marks.append(',');marks.append('?');prefixes[i]=input.substring(0,i+1);}
String order=mode.equals("py")?"length(e.pinyin) DESC,e.pinyin,w.ordinal":"length(e.pinyin) DESC,w.frequency DESC,e.pinyin,w.ordinal";
Set<String> seen=new HashSet<>();
try(Cursor cursor=db.rawQuery("SELECT w.word,length(e.pinyin) FROM entries e JOIN words w ON w.entry=e.id WHERE e."+column+" IN ("+marks+") ORDER BY "+order,prefixes)) {
while(cursor.moveToNext()) {
if(Thread.currentThread().isInterrupted())return new ArrayList<>();
String word=cursor.getString(0);int consume=cursor.getInt(1);
if(seen.add(word+"@"+consume))result.add(new Candidate(word,consume));
}
}
return result;
}
@Override public void close() { if(db!=null){db.close();db=null;} }
}
@@ -0,0 +1,26 @@
package com.sydxky.crabinput;
import org.junit.Test;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.Assert.*;
public class CandidatePagerTest {
@Test public void singleCharactersFillSixteenCellsAndKeepAllResults(){
List<Candidate> input=new ArrayList<>();for(int i=0;i<90;i++)input.add(new Candidate(String.valueOf((char)(0x4E00+i)),3));
List<List<CandidatePager.Row>> pages=CandidatePager.pages(input);assertEquals(6,pages.size());assertEquals(4,pages.get(0).size());
List<Candidate> restored=new ArrayList<>();for(List<CandidatePager.Row> page:pages)for(CandidatePager.Row row:page)for(CandidatePager.Cell cell:row.cells)restored.add(cell.candidate);
assertEquals(input,restored);assertEquals(16,pages.get(0).stream().mapToInt(row->row.cells.size()).sum());
}
@Test public void phrasesUseWiderCellsAndLongPhrasesGetExtraRows(){
List<Candidate> input=Arrays.asList(new Candidate("你好",5),new Candidate("完整词组",4),new Candidate("长词组必须完整展示而不能省略任何文字",19),new Candidate("😀",1));
List<List<CandidatePager.Row>> pages=CandidatePager.pages(input);assertEquals(2,pages.get(0).get(0).cells.size());assertEquals(2,pages.get(0).get(0).cells.get(0).span);
assertEquals(2,pages.get(0).get(1).units);assertEquals(1,pages.get(0).get(2).cells.get(0).span);
for(List<CandidatePager.Row> page:pages)assertTrue(page.stream().mapToInt(row->row.units).sum()<=4);
}
@Test public void emptyListHasNoPhantomButtons(){assertTrue(CandidatePager.pages(new ArrayList<>()).isEmpty());}
@Test public void pairedAndT9MappingsCoverExpectedWords(){
assertEquals("c3754",Lexicon.encode("nihao","14"));assertEquals("64426",Lexicon.encode("nihao","t9"));assertEquals("",Lexicon.encode("a!","14"));
}
}
+51
查看文件
@@ -0,0 +1,51 @@
"""Losslessly build the Android SQLite asset from the licensed original V5 trie."""
import argparse,hashlib,json,pathlib,sqlite3
APP=pathlib.Path(__file__).resolve().parents[1]
def build(rtos):
source=rtos/'third_party/crabKeyboard/pinyinDictV5_20260416'
files={p.name:p.read_bytes()for p in source.glob('*.dat')};entries={}
assert len(files)==41,'Expected the complete upstream V5 dictionary'
def number(text):
result=0
for ch in text:assert 33<=ord(ch)<=126;result=result*94+ord(ch)-33
return result
def walk(key,depth,position,length):
for item in files[f'key{depth}.dat'][position:position+length].decode('ascii').split('\x1e'):
if not item:continue
offset,size=map(number,item[1:].split('\x1f'))
if item[0]=='_':entries[key]=[word for word in files[f'value{depth}.dat'][offset:offset+size].decode('utf-8').split(' ')if word]
else:walk(key+item[0],depth+1,offset,size)
walk('',0,0,len(files['key0.dat']))
frequency_path=rtos/'third_party/jieba/dict.txt'
frequencies={}
for line in frequency_path.read_text(encoding='utf-8').splitlines():
parts=line.split(' ')
if len(parts)>=2 and parts[1].isdigit():frequencies[parts[0]]=int(parts[1])
groups9=['abc','def','ghi','jkl','mno','pqrs','tuv','wxyz'];groups14=['qw','er','ty','ui','op','as','df','gh','jk','l','zx','cv','bn','m']
map9={ch:str(i+2)for i,group in enumerate(groups9)for ch in group}
map14={ch:'0123456789abcd'[i]for i,group in enumerate(groups14)for ch in group}
target=APP/'keyboard/src/main/assets/dictionary.db';target.parent.mkdir(parents=True,exist_ok=True)
temporary=target.with_suffix('.tmp')
if temporary.exists():temporary.unlink()
db=sqlite3.connect(temporary)
db.executescript('PRAGMA page_size=4096; PRAGMA journal_mode=OFF; CREATE TABLE entries(id INTEGER PRIMARY KEY,pinyin TEXT NOT NULL UNIQUE,code9 TEXT NOT NULL,code14 TEXT NOT NULL); CREATE TABLE words(entry INTEGER NOT NULL,ordinal INTEGER NOT NULL,word TEXT NOT NULL,frequency INTEGER NOT NULL,PRIMARY KEY(entry,ordinal)) WITHOUT ROWID;')
for index,(key,words)in enumerate(sorted(entries.items()),1):
db.execute('INSERT INTO entries VALUES(?,?,?,?)',(index,key,''.join(map9[ch]for ch in key),''.join(map14[ch]for ch in key)))
db.executemany('INSERT INTO words VALUES(?,?,?,?)',[(index,ordinal,word,frequencies.get(word,0))for ordinal,word in enumerate(words)])
db.executescript('CREATE INDEX entries_code9 ON entries(code9);CREATE INDEX entries_code14 ON entries(code14);ANALYZE;PRAGMA user_version=1;')
db.commit()
restored={}
for key,word in db.execute('SELECT e.pinyin,w.word FROM entries e JOIN words w ON w.entry=e.id ORDER BY e.pinyin,w.ordinal'):restored.setdefault(key,[]).append(word)
assert restored==entries,'Full dictionary round-trip mismatch'
assert db.execute('PRAGMA integrity_check').fetchone()[0]=='ok';db.close();temporary.replace(target)
report={'keys':len(entries),'candidates':sum(map(len,entries.values())),'longest_word':max(len(word)for words in entries.values()for word in words),'sha256':hashlib.sha256(target.read_bytes()).hexdigest(),'bytes':target.stat().st_size,'round_trip':True,'source_files':{name:hashlib.sha256(data).hexdigest()for name,data in sorted(files.items())},'jieba_sha256':hashlib.sha256(frequency_path.read_bytes()).hexdigest()}
(APP/'keyboard/src/main/assets/dictionary-manifest.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print(json.dumps({k:v for k,v in report.items()if k!='source_files'},ensure_ascii=False))
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--rtos-source',type=pathlib.Path)
args=parser.parse_args();rtos=args.rtos_source
if rtos is None:
rtos=next((p for p in [APP.parent/'com.sydxky.rtos.inputmethod',APP.parent/'RTOS/com.sydxky.rtos.inputmethod']if p.exists()),None)
assert rtos is not None,'Provide the licensed RTOS source with --rtos-source';build(rtos)
+75
查看文件
@@ -0,0 +1,75 @@
"""Capture public screenshots by operating the actual embedded keyboard UI."""
import argparse
from pathlib import Path
import re
import subprocess
import time
import xml.etree.ElementTree as ET
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--serial', required=True)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
def adb(*command):
return subprocess.check_output(['adb', '-s', args.serial, *command], timeout=30)
def tap(value, description=False):
adb('shell', 'uiautomator', 'dump', '/sdcard/crab-capture.xml')
tree = ET.fromstring(adb('exec-out', 'cat', '/sdcard/crab-capture.xml'))
for node in tree.iter('node'):
if node.get('content-desc' if description else 'text') == value:
x1, y1, x2, y2 = map(int, re.findall(r'\d+', node.get('bounds')))
adb('shell', 'input', 'tap', str((x1 + x2)//2), str((y1 + y2)//2))
time.sleep(.22)
return
raise RuntimeError('Missing UI control: ' + value)
def shot(name):
time.sleep(2)
(args.output / (name + '.png')).write_bytes(adb('exec-out', 'screencap', '-p'))
adb('shell', 'input', 'keyevent', '224')
adb('shell', 'wm', 'dismiss-keyguard')
adb('shell', 'am', 'force-stop', 'com.sydxky.crabinput')
adb('shell', 'am', 'start', '-n', 'com.sydxky.crabinput/.MainActivity')
time.sleep(3)
shot('android-home')
tap('demo_text', True)
tap('26键')
shot('android-qwerty')
for letter in 'shi':
tap(letter)
tap('更多候选', True)
shot('android-candidates-16')
tap('返回键盘', True)
# Begin a clean composition by closing and reopening the real editor.
tap('收起键盘', True)
tap('demo_text', True)
tap('14键')
shot('android-fourteen')
for key in ['BN', 'UI', 'GH', 'AS', 'OP']:
tap(key)
tap('更多候选', True)
shot('android-phrases')
tap('你好')
tap('收起键盘', True)
shot('android-result')
adb('shell', 'am', 'start', '-n', 'com.sydxky.crabinput.host/.HostActivity')
time.sleep(2)
tap('fixture_editor', True)
tap('26键')
for letter in 'nihao':
tap(letter)
tap('你好')
tap('收起键盘', True)
shot('android-host')
adb('shell', 'rm', '-f', '/sdcard/crab-capture.xml')
print('Captured 7 real-device UI states; no system IME changes.')
if __name__ == '__main__':
main()
+39
查看文件
@@ -0,0 +1,39 @@
"""Install the release and run embedded-component instrumentation on one explicit device."""
import argparse,hashlib,json,pathlib,subprocess,time
APP=pathlib.Path(__file__).resolve().parents[1]
def main():
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--serial',required=True);parser.add_argument('--output',type=pathlib.Path,required=True)
args=parser.parse_args();args.output.mkdir(parents=True,exist_ok=True)
def adb(*command,binary=False,timeout=30,retries=3):
last=None
for attempt in range(retries):
result=subprocess.run(['adb','-s',args.serial,*command],stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=timeout)
if result.returncode==0:return result.stdout if binary else result.stdout.decode('utf-8',errors='replace').strip()
last=(result.stdout+result.stderr).decode('utf-8',errors='replace').strip()
if command[0]!='install'and'device' not in last.lower()and'closed' not in last.lower():break
print('Device connection interrupted; retrying this command.',flush=True);time.sleep(1)
raise RuntimeError(last)
apk=APP/'app/build/outputs/apk/release/app-release.apk';test=APP/'app/build/outputs/apk/androidTest/release/app-release-androidTest.apk'
assert apk.is_file()and test.is_file()
model=adb('shell','getprop','ro.product.model');sdk=adb('shell','getprop','ro.build.version.sdk')
original=args.output/'original-ime.txt'
if not original.exists():original.write_text(adb('shell','settings','get','secure','default_input_method')+'\n',encoding='utf-8')
host=APP/'host/build/outputs/apk/debug/host-debug.apk'
for path in [apk,test,host]:print(adb('install','-r',str(path),timeout=90),flush=True)
before=adb('shell','settings','get','secure','default_input_method')
print('Default system IME retained; testing the embedded keyboard only.',flush=True)
adb('shell','input','keyevent','224');adb('shell','wm','dismiss-keyguard')
# The instrumentation is bounded short acceptance, not a stress/soak run.
command=['adb','-s',args.serial,'shell','am','instrument','-w','-r','com.sydxky.crabinput.test/androidx.test.runner.AndroidJUnitRunner']
result=subprocess.run(command,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,timeout=240)
output=result.stdout.decode('utf-8',errors='replace');(args.output/'instrumentation.txt').write_text(output,encoding='utf-8');print(output,flush=True)
passed=result.returncode==0 and 'OK (8 tests)'in output and 'FAILURES!!!'not in output
report={'model':model,'sdk':sdk,'version':'1.2.0','apk_sha256':hashlib.sha256(apk.read_bytes()).hexdigest(),'instrumentation_tests':8,'embedded_component':True,'system_ime_unchanged':adb('shell','settings','get','secure','default_input_method')==before,'passed':passed}
(args.output/'device-validation.json').write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
assert passed and report['system_ime_unchanged'],'See instrumentation.txt; failed/unfinished cases are not accepted'
adb('shell','am','start','-n','com.sydxky.crabinput/.MainActivity');time.sleep(.5)
(args.output/'home.png').write_bytes(adb('exec-out','screencap','-p',binary=True))
print(json.dumps(report),flush=True)
if __name__=='__main__':main()
+9
查看文件
@@ -0,0 +1,9 @@
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { google(); mavenCentral() }
}
rootProject.name = 'CrabInputWatch'
include ':app'
include ':keyboard'
include ':host'