fix(输入法): 优化双端联想排序并消除文字刷新闪烁

这个提交包含在:
sydxky 于 2026-11-14 18:39:54 +08:00 提交
1 父节点 bce6f8083b
当前提交 b95e7caae7
31 个文件已更改
+504 -124

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

+5 -3
查看文件
@@ -1,19 +1,21 @@
# 蟹蟹输入 Android应用内键盘组件
基于CrabKeyboard MIT词库,由herry重写的Android手表原生键盘组件,1.2.2 / 10202。提供可集成的 **AAR**、演示APK和独立接入示例;适用Android 8.1 / API 27及以上。
基于CrabKeyboard MIT词库,由herry重写的Android手表原生键盘组件,1.2.3 / 10203。提供可集成的 **AAR**、演示APK和独立接入示例;适用Android 8.1 / API 27及以上。
应用接入后,点击自己的输入框自动展开内置键盘。**不注册系统输入法、不要求用户切换输入法、不改变系统默认输入法。** 圆屏按每行所在高度计算安全弦宽;4×4完整候选,单字16个一页,词组扩宽或增加行高,候选不显示拼音。九宫、标准QWERTY全拼/英文、14键、数字、三页符号、Unicode删除、按住连删及光标编辑。
完全离线,无网络或外部存储权限;组件不持久化输入内容,不启用学习。密码输入默认英文并遮蔽预览。页面隐藏/销毁时清除组件会话与候选,宿主输入框中的内容仍由宿主决定如何处理。异步查询按会话序号隔离,旧结果不能回填新的输入框。
1.2.3 增加未完成拼音补全、上下文续写及词频排序;复用预览、按键及候选控件,按帧合并刷新。联想仅在内存中使用当前输入框光标前的中文,不持久化全文,密码内容不参与。见[本版验收](./VALIDATION_1.2.3.md)。
1.2.2 去掉英文、数字、符号模式未使用的候选栏空位,文字与键盘紧密相接。本版真机间距检查通过;完整测试的限制见[验收记录](./VALIDATION_1.2.2.md)。
## 一分钟接入
将发行版`crab-keyboard-1.2.2.aar`放入宿主`app/libs/`:
将发行版`crab-keyboard-1.2.3.aar`放入宿主`app/libs/`:
```groovy
dependencies { implementation files('libs/crab-keyboard-1.2.2.aar') }
dependencies { implementation files('libs/crab-keyboard-1.2.3.aar') }
```
```java
+9
查看文件
@@ -0,0 +1,9 @@
# Android 1.2.3 联想和刷新验收
2026-11-14,版本1.2.3/10203,最低API27。原始词库无损回读104,837组索引、163,238候选,SQLite schema2新增28,977个续写上下文/52,443候选对,完整性检查通过。新缓存文件名与旧schema隔离,避免升级后误用旧数据库。
7项Java单元测试通过。正式Release APK/AAR、宿主与测试APK构建通过;签名及最终哈希随发行清单。lint均为0错误,演示应用1项备份声明警告,组件2项仅测试依赖版本更新提示。Android复用预览、按键和候选控件,按Choreographer帧合并刷新;提交时合并编辑,候选替换后重置页码。联想只使用当前光标前最多4个连续汉字,不保存正文,密码不参与。
OWW261 Android11/API30上运行3项后台原生组件测试通过:24帧真实原生measure/layout/draw均绘出文字,预览/按键实例保持一致;niha补全你好并接续世界且仅提交一次;密码内容不作为联想上下文。测试在离屏原生View上执行,没有打开Activity、切换前台或修改默认系统输入法;这是组件回归,不是全套前台手势/视觉验收。测试使用组件debug变体,15个运行时class成员与发布AAR逐字节一致;不以此替代发布APK端到端验收。
本版未重跑原9项前台交互,RTOS真机、另一款方表、长时性能/功耗与商店审核未验收。之前1.2.2的6/9结果及截图仅保留作历史资料,不作为本版通过项。
+2 -2
查看文件
@@ -12,8 +12,8 @@ android {
applicationId 'com.sydxky.crabinput'
minSdk 27
targetSdk 35
versionCode 10202
versionName '1.2.2'
versionCode 10203
versionName '1.2.3'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
signingConfigs {
+1 -1
查看文件
@@ -2,7 +2,7 @@ plugins { id 'com.android.application' }
android {
namespace 'com.sydxky.crabinput.host'
compileSdk 37
defaultConfig { applicationId 'com.sydxky.crabinput.host'; minSdk 27; targetSdk 35; versionCode 10202; versionName '1.2.2' }
defaultConfig { applicationId 'com.sydxky.crabinput.host'; minSdk 27; targetSdk 35; versionCode 10203; versionName '1.2.3' }
compileOptions { sourceCompatibility JavaVersion.VERSION_11; targetCompatibility JavaVersion.VERSION_11 }
}
dependencies { implementation project(':keyboard') }
+6 -2
查看文件
@@ -2,8 +2,12 @@ plugins { id 'com.android.library' }
android {
namespace 'com.sydxky.crabinput.keyboard'
compileSdk 37
defaultConfig { minSdk 27; consumerProguardFiles 'consumer-rules.pro' }
defaultConfig { minSdk 27; consumerProguardFiles 'consumer-rules.pro'; testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' }
compileOptions { sourceCompatibility JavaVersion.VERSION_11; targetCompatibility JavaVersion.VERSION_11 }
lint { abortOnError true }
}
dependencies { testImplementation 'junit:junit:4.13.2' }
dependencies {
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
}
@@ -0,0 +1,80 @@
package com.sydxky.crabinput;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.os.SystemClock;
import android.text.Editable;
import android.text.InputType;
import android.view.ContextThemeWrapper;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
import android.widget.TextView;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import java.util.Arrays;
import static org.junit.Assert.*;
/** Native offscreen regression: no Activity, foreground switch or system IME. */
public class SurfaceRegressionTest {
private Context context(){return new ContextThemeWrapper(InstrumentationRegistry.getInstrumentation().getTargetContext(),android.R.style.Theme_Material_Light_NoActionBar);}
private void ui(Runnable action){InstrumentationRegistry.getInstrumentation().runOnMainSync(action);}
private View find(View root,String description){
if(description.contentEquals(root.getContentDescription()==null?"":root.getContentDescription()))return root;
if(root instanceof ViewGroup)for(int i=0;i<((ViewGroup)root).getChildCount();i++){View found=find(((ViewGroup)root).getChildAt(i),description);if(found!=null)return found;}
return null;
}
@Test public void textAndKeysSurviveEveryNativeFrame(){
ui(()->{
KeyboardState state=new KeyboardState();state.mode="py";state.preview="你好";
KeyboardSurface surface=new KeyboardSurface(context(),state,new KeyboardSurface.Actions(){public void action(String v){}public void choose(Candidate v){}public void repeatDelete(boolean v){}});
View preview=null,key=null;
for(int frame=0;frame<24;frame++){
state.preview="你好"+frame;state.composition=frame%2==0?"niha":"nihao";
state.busy=frame%2==0;state.candidates=Arrays.asList(new Candidate(frame%2==0?"你好":"您好",5));
// Real native measure/layout/draw, alternating dimensions also
// exercises the resize path without attaching a screen window.
int width=466+frame%2;
surface.measure(View.MeasureSpec.makeMeasureSpec(width,View.MeasureSpec.EXACTLY),View.MeasureSpec.makeMeasureSpec(466,View.MeasureSpec.EXACTLY));surface.layout(0,0,width,466);
View now=find(surface,"编辑光标"),q=find(surface,"q");assertNotNull(now);assertNotNull(q);
if(preview!=null){assertSame("Preview is never detached and recreated",preview,now);assertSame("Keys survive async candidate changes",key,q);}
preview=now;key=q;assertSame(surface,now.getParent());assertEquals("你好"+frame,((TextView)now).getText().toString());
Bitmap bitmap=Bitmap.createBitmap(width,466,Bitmap.Config.ARGB_8888);surface.draw(new Canvas(bitmap));int ink=0;
for(int y=now.getTop();y<now.getBottom();y++)for(int x=now.getLeft();x<now.getRight();x++){int color=bitmap.getPixel(x,y);if(Color.red(color)<120&&Color.green(color)<120&&Color.blue(color)<120)ink++;}
assertTrue("Every frame paints the entered text",ink>20);bitmap.recycle();
}
});
}
private KeyboardController session(EditText editor,int type){
EditorInfo info=new EditorInfo();info.inputType=type;
BaseInputConnection input=new BaseInputConnection(editor,true){@Override public Editable getEditable(){return editor.getText();}};
return new KeyboardController(context(),input,info,()->{});
}
private void awaitQuery(KeyboardController controller){
long until=SystemClock.uptimeMillis()+8000;boolean[] busy={true};
do{SystemClock.sleep(40);ui(()->busy[0]=controller.state.busy);}while(busy[0]&&SystemClock.uptimeMillis()<until);
assertFalse("Offline query completes",busy[0]);
}
@Test public void completionAndContinuationCommitExactlyOnce(){
KeyboardController[] value=new KeyboardController[1];EditText[] editor=new EditText[1];
ui(()->{editor[0]=new EditText(context());value[0]=session(editor[0],InputType.TYPE_CLASS_TEXT);value[0].action("mode:py");for(char ch:"niha".toCharArray())value[0].action("key:"+ch);});
KeyboardController controller=value[0];
try{
awaitQuery(controller);ui(()->{assertEquals("你好",controller.state.candidates.get(0).word);controller.choose(controller.state.candidates.get(0));});
awaitQuery(controller);ui(()->{assertEquals("你好",editor[0].getText().toString());assertTrue(controller.state.prediction);assertEquals("世界",controller.state.candidates.get(0).word);controller.choose(controller.state.candidates.get(0));});
awaitQuery(controller);ui(()->assertEquals("你好世界",editor[0].getText().toString()));
}finally{ui(controller::close);}
}
@Test public void passwordDoesNotProvidePredictionContext(){
ui(()->{
EditText editor=new EditText(context());editor.setText("你好");editor.setSelection(editor.length());
KeyboardController controller=session(editor,InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_PASSWORD);
try{controller.action("space");assertTrue(controller.state.secret);assertFalse(controller.state.prediction);assertTrue(controller.state.candidates.isEmpty());assertEquals("",controller.state.preview);}
finally{controller.close();}
});
}
}
@@ -1,9 +1,12 @@
{
"schema_version": 2,
"association_prefixes": 28977,
"association_candidates": 52443,
"keys": 104837,
"candidates": 163238,
"longest_word": 4,
"sha256": "fa01b1ab0264d8b271a32338b93a64b8670a71d57264c8586c89ae3cf3ddd8a2",
"bytes": 12644352,
"sha256": "6575841fb5d6232d1624bd91c19e8f39b030eca4a4e09a6bcff21e12b12b27d4",
"bytes": 13688832,
"round_trip": true,
"source_files": {
"key0.dat": "795a5316089b67a7bffabf87080bd61f3720f9e0fd2f2866a092901f99b794e8",
@@ -3,5 +3,10 @@ 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; }
final int frequency;
final boolean completion;
public Candidate(String word, int consume) { this(word,consume,0,false); }
Candidate(String word,int consume,int frequency,boolean completion) {
this.word=word;this.consume=consume;this.frequency=frequency;this.completion=completion;
}
}
@@ -0,0 +1,24 @@
package com.sydxky.crabinput;
import java.util.List;
/** Shared policy for complete matches, unfinished syllables and prefix fallbacks. */
final class CandidateRanking {
private static int category(Candidate item,int length) {
return item.completion?1:item.consume==length?0:2;
}
private static int contextRank(Candidate item,List<Candidate> predictions) {
for(int i=0;i<predictions.size();i++)if(predictions.get(i).word.equals(item.word))return 8-i;
return 0;
}
static void sort(List<Candidate> items,int length,List<Candidate> predictions) {
items.sort((a,b)->{
int order=Integer.compare(category(a,length),category(b,length));
if(order==0)order=Integer.compare(b.consume,a.consume);
if(order==0)order=Integer.compare(contextRank(b,predictions),contextRank(a,predictions));
if(order==0)order=Integer.compare(b.frequency,a.frequency);
return order;
});
}
private CandidateRanking(){}
}
@@ -27,7 +27,7 @@ final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable
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 lookup=()->query(revision,state.composition,state.mode,contextText());
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){
@@ -44,26 +44,34 @@ final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable
}
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 String contextText(){if(state.secret)return "";CharSequence text=input.getTextBeforeCursor(12,0);return text==null?"":text.toString();}
private void clearCandidates(){state.candidates=new ArrayList<>();state.pages=new ArrayList<>();}
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();
invalidateLookup();state.page=0;
boolean prediction=state.composition.isEmpty();
if(!active||!chinese()||prediction&&(state.secret||!contextText().matches("(?s).*[\\u3400-\\u9fff]$"))){clearCandidates();state.prediction=false;refresh();return;}
if(prediction!=state.prediction)clearCandidates();
state.prediction=prediction;state.busy=true;main.postDelayed(lookup,40);refresh();
}
private void query(int owner,String composition,String mode){
private void query(int owner,String composition,String mode,String context){
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();}});}
try {List<Candidate> result=composition.isEmpty()?lexicon.predict(context):lexicon.lookup(composition,mode,context);main.post(()->{if(!active||revision!=owner)return;state.candidates=result;state.pages=CandidatePager.pages(result);state.page=0;state.busy=false;state.message=result.isEmpty()&&!composition.isEmpty()?"暂无匹配,请退格修改":state.prediction?"联想":"离线输入";refresh();});}
catch(Exception ignored){main.post(()->{if(active&&revision==owner){clearCandidates();state.busy=false;state.message="词库暂不可用,可切换英文";refresh();}});}
});
}
private void commit(String text){if(active)input.commitText(text,1);refresh();}
private void commit(String text){if(!active)return;input.commitText(text,1);if(chinese())schedule();else refresh();}
@Override public void choose(Candidate item){
if(!active||state.busy||state.composition.isEmpty()||!state.candidates.contains(item))return;
if(!active||state.busy||state.composition.isEmpty()&&(!state.prediction||item.consume!=0)||!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();
invalidateLookup();input.beginBatchEdit();
try{input.commitText(item.word,1);state.composition=remaining;state.expanded=false;}
finally{input.endBatchEdit();}
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();
CharSequence selection=input.getSelectedText(0);if(selection!=null&&selection.length()>0)input.commitText("",1);else input.deleteSurroundingTextInCodePoints(1,0);schedule();
}
@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){
@@ -88,8 +96,8 @@ final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable
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 "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));schedule();return;}break;
case "raw":if(state.mode.equals("py")&&!state.composition.isEmpty()){String text=state.composition;state.composition="";commit(text);}return;
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;}
@@ -5,7 +5,7 @@ import java.util.List;
final class KeyboardState {
String mode="t9",composition="",preview="",message="离线输入";
boolean expanded,tools,upper,secret,busy;
boolean expanded,tools,upper,secret,busy,prediction;
int page,symbolPage;
List<Candidate> candidates=new ArrayList<>();
List<List<CandidatePager.Row>> pages=new ArrayList<>();
@@ -16,6 +16,10 @@ import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.LinkedHashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** Native accessible buttons laid out within the actual round screen chord. */
@android.annotation.SuppressLint("ViewConstructor") // Created by the IME, never inflated from XML.
@@ -26,7 +30,11 @@ final class KeyboardSurface extends ViewGroup {
private final Actions actions;
private final boolean round;
private final List<Placement> placements=new ArrayList<>();
private final Map<String,View> children=new LinkedHashMap<>();
private final Set<String> used=new HashSet<>();
private boolean refreshPosted;
private int builtWidth,builtHeight;
private final Runnable render=()->{refreshPosted=false;if(builtWidth>0)build(builtWidth,builtHeight);requestLayout();invalidate();};
private float sx=1,sy=1;
private static final class Placement {
final View view;final Rect bounds;
@@ -41,7 +49,7 @@ final class KeyboardSurface extends ViewGroup {
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(); }
void refresh() {if(!refreshPosted){refreshPosted=true;postOnAnimation(render);}}
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));
}
@@ -53,25 +61,29 @@ final class KeyboardSurface extends ViewGroup {
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)));
if(view.getParent()==null)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);
private TextView label(String key,String text,float font,int color) {
used.add(key);TextView view=(TextView)children.get(key);
if(view==null){view=new TextView(getContext());children.put(key,view);}
if(!view.getText().toString().equals(text))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);
private Button button(String key,String text,String description,float font,boolean primary,Runnable action) {
used.add(key);Button button=(Button)children.get(key);boolean created=button==null;
if(created){button=new Button(getContext());children.put(key,button);}
if(!button.getText().toString().equals(text))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;
if(created){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));
Button button=button("action:"+value,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);
@@ -84,31 +96,33 @@ final class KeyboardSurface extends ViewGroup {
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();
builtWidth=width;builtHeight=height;sx=width/410f;sy=height/410f;placements.clear();used.clear();
try{
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);
place(label("title",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);
TextView previewView=label("preview",preview,21,INK);previewView.setMaxLines(1);previewView.setEllipsize(android.text.TextUtils.TruncateAt.START);
previewView.setOnClickListener(v->actions.action("tools"));previewView.setContentDescription("编辑光标");
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);
TextView compose=label("composition",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);}
for(int i=0;i<count;i++){Candidate item=state.candidates.get(i);Button b=button("candidate:"+i,item.word,item.word,22,false,()->actions.choose(item));b.setEnabled(!state.busy);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);
else place(label("candidate-status",state.busy?"正在查词…":state.message,12,MUTED),left,candidateY,available,32);
}
// A direct-entry mode has no candidate strip; attach its keys to the
// composition caption instead of reserving an invisible suggestion row.
@@ -128,19 +142,24 @@ final class KeyboardSurface extends ViewGroup {
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);
}finally{
java.util.Iterator<Map.Entry<String,View>> iterator=children.entrySet().iterator();
while(iterator.hasNext()){Map.Entry<String,View> entry=iterator.next();if(!used.contains(entry.getKey())){removeView(entry.getValue());iterator.remove();}}
}
}
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);
place(label("page-info",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;
int rowIndex=0;
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));
Candidate item=entry.candidate;Button button=button("grid:"+rowIndex+":"+slot,item.word,item.word,entry.span==1?25:entry.span==2?23:20,false,()->actions.choose(item));button.setEnabled(!state.busy);
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;
gridY+=row.units*unit;rowIndex++;
}
float pagerY=y+25+4*unit+3;
row(new String[]{"‹","›"},new String[]{"previous","next"},pagerY,29,27);
@@ -150,7 +169,7 @@ final class KeyboardSurface extends ViewGroup {
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);
place(label("privacy",state.secret?"私密输入 · 不学习、不保存":"完全离线 · 不采集输入内容",12,MUTED),45,y+150,320,26);
bottom(y+190,true);
}
private void bottom(float y,boolean back) {
@@ -158,8 +177,6 @@ final class KeyboardSurface extends ViewGroup {
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 · 再点符号翻页":"数字";}
@@ -173,5 +190,5 @@ final class KeyboardSurface extends ViewGroup {
}
@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();}
@Override protected void onDetachedFromWindow(){removeCallbacks(render);refreshPosted=false;actions.repeatDelete(false);super.onDetachedFromWindow();}
}
@@ -29,9 +29,9 @@ public final class Lexicon implements AutoCloseable {
}
private void open() throws Exception {
if(db!=null)return;
File directory=context.getNoBackupFilesDir();File target=new File(directory,"crab-v5-1.db");
File directory=context.getNoBackupFilesDir();File target=new File(directory,"crab-v5-2.db");
if(!target.isFile()) {
File temporary=new File(directory,"crab-v5-1.tmp");
File temporary=new File(directory,"crab-v5-2.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();
}
@@ -40,6 +40,21 @@ public final class Lexicon implements AutoCloseable {
db=SQLiteDatabase.openDatabase(target.getPath(),null,SQLiteDatabase.OPEN_READONLY|SQLiteDatabase.NO_LOCALIZED_COLLATORS);
}
public List<Candidate> lookup(String input,String mode) throws Exception {
return lookup(input,mode,"");
}
public List<Candidate> predict(String context) throws Exception {
List<Candidate> result=new ArrayList<>();
java.util.regex.Matcher match=java.util.regex.Pattern.compile("[\\u3400-\\u9fff]{1,4}$").matcher(context==null?"":context);
if(!match.find())return result;
open();String tail=match.group();Set<String> seen=new HashSet<>();
for(int length=tail.length();length>0&&result.size()<8;length--){
try(Cursor cursor=db.rawQuery("SELECT word,frequency FROM associations WHERE prefix=? ORDER BY frequency DESC,word LIMIT 8",new String[]{tail.substring(tail.length()-length)})){
while(cursor.moveToNext()&&result.size()<8){String word=cursor.getString(0);if(seen.add(word))result.add(new Candidate(word,0,cursor.getInt(1),false));}
}
}
return result;
}
public List<Candidate> lookup(String input,String mode,String context) 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();
@@ -48,13 +63,25 @@ public final class Lexicon implements AutoCloseable {
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)) {
try(Cursor cursor=db.rawQuery("SELECT w.word,length(e.pinyin),w.frequency 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));
if(seen.add(word+"@"+consume))result.add(new Candidate(word,consume,cursor.getInt(2),false));
}
}
if(input.length()>=2){
String sql="SELECT w.word,w.frequency FROM entries e JOIN words w ON w.entry=e.id WHERE e."+column+">? AND e."+column+"<? AND length(e.pinyin)<=? ORDER BY w.frequency DESC,length(e.pinyin),e.pinyin,w.ordinal LIMIT 128";
try(Cursor cursor=db.rawQuery(sql,new String[]{input,input+'\uffff',Integer.toString(input.length()+4)})){
int added=0;
while(cursor.moveToNext()&&added<12){
if(Thread.currentThread().isInterrupted())return new ArrayList<>();
String word=cursor.getString(0);
if(seen.add(word+"@"+input.length())){result.add(new Candidate(word,input.length(),cursor.getInt(1),true));added++;}
}
}
}
CandidateRanking.sort(result,input.length(),predict(context));
return result;
}
@Override public void close() { if(db!=null){db.close();db=null;} }
@@ -0,0 +1,29 @@
package com.sydxky.crabinput;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.Test;
import static org.junit.Assert.*;
public class CandidateRankingTest {
@Test public void incompleteSyllablesBeatShorterFallbackWithoutLosingThem(){
Candidate shorter=new Candidate("你",2,234587,false),complete=new Candidate("你好",4,725,true);
List<Candidate> items=new ArrayList<>(Arrays.asList(shorter,complete));
CandidateRanking.sort(items,4,Collections.emptyList());
assertSame(complete,items.get(0));assertTrue(items.contains(shorter));
}
@Test public void exactSpellingBeatsCompletionsAndUsesWordFrequency(){
Candidate common=new Candidate("世界",6,34387,false),rare=new Candidate("使节",6,497,false),longer=new Candidate("世界观",6,100000,true);
List<Candidate> items=new ArrayList<>(Arrays.asList(rare,longer,common));
CandidateRanking.sort(items,6,Collections.emptyList());
assertEquals(Arrays.asList(common,rare,longer),items);
}
@Test public void matchingContextDisambiguatesWithoutInjectingCandidates(){
Candidate plain=new Candidate("背景",7,34488,false),contextual=new Candidate("北京",7,3342,false);
List<Candidate> items=new ArrayList<>(Arrays.asList(plain,contextual));
CandidateRanking.sort(items,7,Arrays.asList(new Candidate("北京",0),new Candidate("不存在",0)));
assertSame(contextual,items.get(0));assertEquals(2,items.size());
}
}
+8 -3
查看文件
@@ -1,5 +1,5 @@
"""Losslessly build the Android SQLite asset from the licensed original V5 trie."""
import argparse,hashlib,json,pathlib,sqlite3
import argparse,hashlib,importlib.util,json,pathlib,sqlite3
APP=pathlib.Path(__file__).resolve().parents[1]
def build(rtos):
@@ -22,6 +22,9 @@ def build(rtos):
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])
spec=importlib.util.spec_from_file_location('crab_dictionary',rtos/'scripts/convert-dictionary.py')
converter=importlib.util.module_from_spec(spec);spec.loader.exec_module(converter)
associations=converter.associations(entries,frequencies)
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}
@@ -33,13 +36,15 @@ def build(rtos):
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.execute('CREATE TABLE associations(prefix TEXT NOT NULL,word TEXT NOT NULL,frequency INTEGER NOT NULL,PRIMARY KEY(prefix,word)) WITHOUT ROWID')
db.executemany('INSERT INTO associations VALUES(?,?,?)',[(prefix,word,frequency)for prefix,rows in associations.items()for word,frequency in rows])
db.executescript('CREATE INDEX entries_code9 ON entries(code9);CREATE INDEX entries_code14 ON entries(code14);ANALYZE;PRAGMA user_version=2;')
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()}
report={'schema_version':2,'association_prefixes':len(associations),'association_candidates':sum(map(len,associations.values())),'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))
+13 -8
查看文件
@@ -4,6 +4,7 @@ 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)
parser.add_argument('--background-regression',action='store_true',help='Run native offscreen component checks without launching an Activity or switching the foreground')
args=parser.parse_args();args.output.mkdir(parents=True,exist_ok=True)
def adb(*command,binary=False,timeout=30,retries=3):
last=None
@@ -14,26 +15,30 @@ def main():
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'
apk=APP/'app/build/outputs/apk/release/app-release.apk';test=APP/('keyboard/build/outputs/apk/androidTest/debug/keyboard-debug-androidTest.apk'if args.background_regression else'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)
for path in ([test]if args.background_regression else[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')
if not args.background_regression: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']
command=['adb','-s',args.serial,'shell','am','instrument','-w','-r']
if args.background_regression:command+=['-e','class','com.sydxky.crabinput.SurfaceRegressionTest']
command+=[('com.sydxky.crabinput.keyboard.test'if args.background_regression else'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 (9 tests)'in output and 'FAILURES!!!'not in output
report={'model':model,'sdk':sdk,'version':'1.2.2','apk_sha256':hashlib.sha256(apk.read_bytes()).hexdigest(),'instrumentation_tests':9,'embedded_component':True,'system_ime_unchanged':adb('shell','settings','get','secure','default_input_method')==before,'passed':passed}
expected=3 if args.background_regression else 9
passed=result.returncode==0 and f'OK ({expected} tests)'in output and 'FAILURES!!!'not in output
report={'model':model,'sdk':sdk,'version':'1.2.3','release_apk_sha256':hashlib.sha256(apk.read_bytes()).hexdigest(),'instrumentation_tests':expected,'verification_mode':'native_offscreen_component'if args.background_regression else'interactive','interactive_tests_run':not args.background_regression,'component_variant':'debug'if args.background_regression else'release','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))
if not args.background_regression:
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()