perf(输入法): 降低查词与候选内存开销并放大等尺寸26键
这个提交包含在:
1 父节点
100757f684
当前提交
7cd1ab906c
33 个文件已更改
+513
-126
没有与搜索条件相匹配的文件
+5
-3
@@ -1,11 +1,13 @@
|
||||
# 蟹蟹输入 Android应用内键盘组件
|
||||
|
||||
基于CrabKeyboard MIT词库,由herry重写的Android手表原生键盘组件,1.2.4 / 10204。提供可集成的 **AAR**、演示APK和独立接入示例;适用Android 8.1 / API 27及以上。
|
||||
基于CrabKeyboard MIT词库,由herry重写的Android手表原生键盘组件,1.2.5 / 10205。提供可集成的 **AAR**、演示APK和独立接入示例;适用Android 8.1 / API 27及以上。
|
||||
|
||||
应用接入后,点击自己的输入框自动展开内置键盘。**不注册系统输入法、不要求用户切换输入法、不改变系统默认输入法。** 圆屏按每行所在高度计算安全弦宽;4×4完整候选,单字16个一页,词组扩宽或增加行高,候选不显示拼音。九宫、标准QWERTY全拼/英文、14键、数字、三页符号、Unicode删除、按住连删及光标编辑。
|
||||
|
||||
完全离线,无网络或外部存储权限;组件不持久化输入内容,不启用学习。密码输入默认英文并遮蔽预览。页面隐藏/销毁时清除组件会话与候选,宿主输入框中的内容仍由宿主决定如何处理。异步查询按会话序号隔离,旧结果不能回填新的输入框。
|
||||
|
||||
1.2.5 取消过期SQLite查询,后台只创建整数分页计划,当前页才生成候选单元;文字更新复用键盘布局。26字母等宽等高并扩大触控区,圆方屏安全边界通过,保留大小写切换。8项Java和7项后台原生组件测试通过;验证范围见[本版验收](./VALIDATION_1.2.5.md)。
|
||||
|
||||
1.2.4 去掉预览与示例编辑框的额外字间距,扩大26键;每排继续按实际屏幕弦宽计算安全边界。点“abc”进入英文,随后“大写/小写”按钮显示下一步动作,字母标签与实际输入保持一致。见[本版验收](./VALIDATION_1.2.4.md)。
|
||||
|
||||
1.2.3 增加未完成拼音补全、上下文续写及词频排序;复用预览、按键及候选控件,按帧合并刷新。联想仅在内存中使用当前输入框光标前的中文,不持久化全文,密码内容不参与。见[本版验收](./VALIDATION_1.2.3.md)。
|
||||
@@ -14,10 +16,10 @@
|
||||
|
||||
## 一分钟接入
|
||||
|
||||
将发行版`crab-keyboard-1.2.4.aar`放入宿主`app/libs/`:
|
||||
将发行版`crab-keyboard-1.2.5.aar`放入宿主`app/libs/`:
|
||||
|
||||
```groovy
|
||||
dependencies { implementation files('libs/crab-keyboard-1.2.4.aar') }
|
||||
dependencies { implementation files('libs/crab-keyboard-1.2.5.aar') }
|
||||
```
|
||||
|
||||
```java
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Android 1.2.5 内存、刷新及等大26键验收
|
||||
|
||||
2026-11-14,版本1.2.5/10205,最低Android8.1/API27。
|
||||
|
||||
候选分页只保留整数页起点,后台线程计算页计划,当前页才生成单元对象;不再每次查词创建所有候选页。取消旧Future同时通过CancellationSignal取消SQLite读取。预编译中文上下文正则。键盘结构未变时只更新文字及候选事件,不重建Placements或逐键重设样式;仍按帧合并刷新并复用控件,避免输入闪动。
|
||||
|
||||
26字母维持标准10/9/7三排,以三排安全弦宽计算统一单元宽度,方屏充分使用宽度,缩小间距并增加高度。所有字母等宽等高(像素取整最多1px),保留明确的大写/小写按钮和qQq实际输入。圆屏同步保留底部操作区。
|
||||
|
||||
8项Java测试通过,包含12,000候选的按需分页、全量顺序及完整性。Release APK/AAR、宿主、后台组件测试APK构建通过,SQLite无损回读104,837组索引/163,238候选。lint 0错误:演示1项备份声明警告,组件2项测试依赖提示。最终APK为1.2.5/10205,API27、zipalign、原生产证书1名签名者和v2签名通过。APK SHA-256:b61729cb9698be794c2eb406d3eb3c9c770ad727e67d6f2fdf722aa9f220eea2。
|
||||
|
||||
OWW261 Android11/API30上7项后台离屏原生组件测试通过:24帧实际measure/layout/draw文字非空、控件不重建;相同尺寸连续24次文字/候选更新只创建一次布局;补全/续词只提交一次;密码上下文隔离;实际点击大小写切换输入qQq;模拟圆形466/192与方形466、390×450、192尺寸的26键等大、全部控件矩形及圆屏按钮四角边界;取消词库查询及后续恢复。
|
||||
|
||||
测试未打开Activity、切换前台或修改系统默认输入法,使用debug组件,16个运行时class与发布AAR逐字节相同。最终APK没有安装到共享手表,原9项前台交互未重跑;这些组件测试不代替发布APK前台端到端视觉验收。没有新的Android前台截图。没有Android进程内存、真实打字延迟、长期功耗测量;另一款实体方表和商店审核未验收。
|
||||
+2
-2
@@ -12,8 +12,8 @@ android {
|
||||
applicationId 'com.sydxky.crabinput'
|
||||
minSdk 27
|
||||
targetSdk 35
|
||||
versionCode 10204
|
||||
versionName '1.2.4'
|
||||
versionCode 10205
|
||||
versionName '1.2.5'
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
signingConfigs {
|
||||
|
||||
@@ -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 10204; versionName '1.2.4' }
|
||||
defaultConfig { applicationId 'com.sydxky.crabinput.host'; minSdk 27; targetSdk 35; versionCode 10205; versionName '1.2.5' }
|
||||
compileOptions { sourceCompatibility JavaVersion.VERSION_11; targetCompatibility JavaVersion.VERSION_11 }
|
||||
}
|
||||
dependencies { implementation project(':keyboard') }
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.sydxky.crabinput;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.os.SystemClock;
|
||||
import android.os.CancellationSignal;
|
||||
import android.os.OperationCanceledException;
|
||||
import android.text.Editable;
|
||||
import android.text.InputType;
|
||||
import android.view.ContextThemeWrapper;
|
||||
@@ -22,6 +25,7 @@ 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 Context context(boolean round){Context base=context();Configuration configuration=new Configuration(base.getResources().getConfiguration());configuration.screenLayout=(configuration.screenLayout&~Configuration.SCREENLAYOUT_ROUND_MASK)|(round?Configuration.SCREENLAYOUT_ROUND_YES:Configuration.SCREENLAYOUT_ROUND_NO);return new ContextThemeWrapper(base.createConfigurationContext(configuration),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;
|
||||
@@ -98,4 +102,41 @@ public class SurfaceRegressionTest {
|
||||
}finally{controller.close();}
|
||||
});
|
||||
}
|
||||
@Test public void enlargedAlphabetKeysHaveEqualSizeOnRoundAndRectangularScreens(){
|
||||
ui(()->{
|
||||
for(boolean round:new boolean[]{false,true})for(int[] size:round?new int[][]{{466,466},{192,192}}:new int[][]{{466,466},{390,450},{192,192}})for(String mode:new String[]{"py","en"}){
|
||||
Context themed=context(round);KeyboardState state=new KeyboardState();state.mode=mode;
|
||||
KeyboardSurface surface=new KeyboardSurface(themed,state,new KeyboardSurface.Actions(){public void action(String v){}public void choose(Candidate v){}public void repeatDelete(boolean v){}});
|
||||
int width=size[0],height=size[1];surface.measure(View.MeasureSpec.makeMeasureSpec(width,View.MeasureSpec.EXACTLY),View.MeasureSpec.makeMeasureSpec(height,View.MeasureSpec.EXACTLY));surface.layout(0,0,width,height);
|
||||
int minW=Integer.MAX_VALUE,maxW=0,minH=Integer.MAX_VALUE,maxH=0;
|
||||
for(char letter:"qwertyuiopasdfghjklzxcvbnm".toCharArray()){
|
||||
View key=find(surface,String.valueOf(letter));assertNotNull(key);minW=Math.min(minW,key.getWidth());maxW=Math.max(maxW,key.getWidth());minH=Math.min(minH,key.getHeight());maxH=Math.max(maxH,key.getHeight());
|
||||
}
|
||||
assertTrue("All 26 letters have the same width within pixel rounding",maxW-minW<=1);assertTrue("All 26 letters have the same height within pixel rounding",maxH-minH<=1);assertTrue(minW>width*.07);assertTrue(minH>height*.13);
|
||||
for(int i=0;i<surface.getChildCount();i++){
|
||||
View child=surface.getChildAt(i);assertTrue("No child overflows",child.getWidth()>0&&child.getHeight()>0&&child.getLeft()>=0&&child.getTop()>=0&&child.getRight()<=width&&child.getBottom()<=height);
|
||||
if(round&&child instanceof android.widget.Button)for(int x:new int[]{child.getLeft(),child.getRight()})for(int y:new int[]{child.getTop(),child.getBottom()})assertTrue("Round buttons stay inside safe screen",Math.hypot(x-width/2.0,y-height/2.0)<=width/2.0-1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@Test public void sameSizeEditsDoNotRebuildTheKeyboard(){
|
||||
ui(()->{
|
||||
KeyboardState state=new KeyboardState();state.mode="py";state.candidates=Arrays.asList(new Candidate("你好",5));
|
||||
KeyboardSurface surface=new KeyboardSurface(context(),state,new KeyboardSurface.Actions(){public void action(String v){}public void choose(Candidate v){}public void repeatDelete(boolean v){}});
|
||||
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));
|
||||
surface.forceLayout();surface.measure(View.MeasureSpec.makeMeasureSpec(466,View.MeasureSpec.EXACTLY),View.MeasureSpec.makeMeasureSpec(466,View.MeasureSpec.EXACTLY));surface.layout(0,0,466,466);
|
||||
assertEquals("Text-only updates reuse placements and key layout",1,surface.layoutBuilds);assertEquals("输入"+frame,((TextView)find(surface,"编辑光标")).getText().toString());assertNotNull(find(surface,frame%2==0?"你好":"您好"));
|
||||
}
|
||||
});
|
||||
}
|
||||
@Test public void cancelledDictionaryQueryDoesNotBreakFollowingInput()throws Exception{
|
||||
try(Lexicon lexicon=new Lexicon(context())){
|
||||
CancellationSignal signal=new CancellationSignal();signal.cancel();
|
||||
try{lexicon.lookup("nihao","py","",signal);fail("Cancelled lookup must stop");}catch(OperationCanceledException expected){}
|
||||
try{lexicon.predict("你好",signal);fail("Cancelled continuation must stop");}catch(OperationCanceledException expected){}
|
||||
assertEquals("你好",lexicon.lookup("niha","py").get(0).word);assertEquals("世界",lexicon.predict("你好").get(0).word);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.sydxky.crabinput;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.AbstractList;
|
||||
import java.util.RandomAccess;
|
||||
|
||||
/** 4 columns x 4 row units; long phrases get full width and extra row units. */
|
||||
public final class CandidatePager {
|
||||
@@ -16,7 +18,7 @@ public final class CandidatePager {
|
||||
public int slots;
|
||||
Row(int units) { this.units=units; }
|
||||
}
|
||||
public static List<List<Row>> pages(List<Candidate> candidates) {
|
||||
private static List<List<Row>> materialize(List<Candidate> candidates) {
|
||||
List<List<Row>> pages=new ArrayList<>();List<Row> page=new ArrayList<>();
|
||||
Row row=null;int used=0;
|
||||
for(Candidate candidate:candidates) {
|
||||
@@ -33,5 +35,27 @@ public final class CandidatePager {
|
||||
if(!page.isEmpty())pages.add(page);
|
||||
return pages;
|
||||
}
|
||||
private static final class Pages extends AbstractList<List<Row>> implements RandomAccess {
|
||||
private final List<Candidate> candidates;
|
||||
private final List<Integer> starts=new ArrayList<>();
|
||||
Pages(List<Candidate> candidates){
|
||||
this.candidates=candidates;int used=0,slots=0,rowUnits=0;
|
||||
for(int i=0;i<candidates.size();i++){
|
||||
String word=candidates.get(i).word;int length=word.codePointCount(0,word.length());
|
||||
int span=length==1?1:length<=4?2:4,units=span==4?Math.max(1,(length+11)/12):1;
|
||||
if(slots>0&&(slots+span>4||rowUnits!=units)){used+=rowUnits;slots=0;}
|
||||
if(starts.isEmpty()||used+units>4){starts.add(i);used=0;slots=0;}
|
||||
if(slots==0)rowUnits=units;
|
||||
slots+=span;if(slots==4){used+=rowUnits;slots=0;}
|
||||
}
|
||||
}
|
||||
@Override public int size(){return starts.size();}
|
||||
@Override public List<Row> get(int page){
|
||||
if(page<0||page>=size())throw new IndexOutOfBoundsException();
|
||||
int end=page+1<size()?starts.get(page+1):candidates.size();
|
||||
return materialize(candidates.subList(starts.get(page),end)).get(0);
|
||||
}
|
||||
}
|
||||
public static List<List<Row>> pages(List<Candidate> candidates){return new Pages(candidates);}
|
||||
private CandidatePager() {}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import android.os.CancellationSignal;
|
||||
|
||||
/** One transient in-app editing session. It never selects a system IME. */
|
||||
final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable {
|
||||
@@ -25,6 +26,7 @@ final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable
|
||||
private final Runnable dismiss;
|
||||
private final Context context;
|
||||
private Future<?> pending;
|
||||
private CancellationSignal cancellation;
|
||||
private int revision;
|
||||
private boolean active=true,repeat,closed;
|
||||
private final Runnable lookup=()->query(revision,state.composition,state.mode,contextText());
|
||||
@@ -37,7 +39,7 @@ final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable
|
||||
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 invalidateLookup(){revision++;main.removeCallbacks(lookup);if(cancellation!=null){cancellation.cancel();cancellation=null;}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();
|
||||
@@ -54,8 +56,9 @@ final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable
|
||||
state.prediction=prediction;state.busy=true;main.postDelayed(lookup,40);refresh();
|
||||
}
|
||||
private void query(int owner,String composition,String mode,String context){
|
||||
CancellationSignal signal=new CancellationSignal();cancellation=signal;
|
||||
pending=worker.submit(()->{
|
||||
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();});}
|
||||
try {List<Candidate> result=composition.isEmpty()?lexicon.predict(context,signal):lexicon.lookup(composition,mode,context,signal);List<List<CandidatePager.Row>> pages=CandidatePager.pages(result);main.post(()->{if(!active||revision!=owner)return;state.candidates=result;state.pages=pages;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();}});}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,7 +34,11 @@ final class KeyboardSurface extends ViewGroup {
|
||||
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 String builtKey="";
|
||||
private List<Candidate> builtCandidates;
|
||||
int layoutBuilds;
|
||||
private final Runnable render=this::renderFrame;
|
||||
private void renderFrame(){refreshPosted=false;if(builtWidth>0){if(!state.expanded&&!state.tools&&layoutKey().equals(builtKey))updateContent();else{build(builtWidth,builtHeight);requestLayout();}}else requestLayout();invalidate();}
|
||||
private float sx=1,sy=1;
|
||||
private static final class Placement {
|
||||
final View view;final Rect bounds;
|
||||
@@ -95,10 +99,28 @@ final class KeyboardSurface extends ViewGroup {
|
||||
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 String layoutKey(){return state.mode+":"+state.upper+":"+state.symbolPage+":"+state.tools+":"+state.expanded+":"+state.page+":"+Math.min(3,state.candidates.size());}
|
||||
private void text(String key,String value){TextView view=(TextView)children.get(key);if(view!=null&&!view.getText().toString().equals(value))view.setText(value);}
|
||||
private String compositionText(){
|
||||
if(state.composition.isEmpty())return modeName();
|
||||
if(!state.mode.equals("14"))return state.composition;
|
||||
StringBuilder result=new StringBuilder();for(char ch:state.composition.substring(Math.max(0,state.composition.length()-6)).toCharArray())result.append(Lexicon.GROUP14["0123456789abcd".indexOf(ch)].toUpperCase(Locale.ROOT)).append(' ');return result.toString();
|
||||
}
|
||||
private void updateContent(){
|
||||
text("preview",state.secret?"私密输入":state.preview.isEmpty()?"开始输入…":state.preview);text("composition",compositionText());
|
||||
text("candidate-status",state.busy?"正在查词…":state.message);
|
||||
for(int i=0;i<Math.min(3,state.candidates.size());i++){
|
||||
Candidate candidate=state.candidates.get(i);Button button=(Button)children.get("candidate:"+i);if(button==null)continue;
|
||||
if(!button.getText().toString().equals(candidate.word))button.setText(candidate.word);
|
||||
button.setContentDescription(candidate.word);button.setEnabled(!state.busy);
|
||||
if(builtCandidates!=state.candidates)button.setOnClickListener(v->actions.choose(candidate));
|
||||
}
|
||||
builtCandidates=state.candidates;
|
||||
}
|
||||
private void build(int width,int height) {
|
||||
builtWidth=width;builtHeight=height;sx=width/410f;sy=height/410f;placements.clear();used.clear();
|
||||
builtWidth=width;builtHeight=height;builtKey=layoutKey();builtCandidates=state.candidates;layoutBuilds++;sx=width/410f;sy=height/410f;placements.clear();used.clear();
|
||||
try{
|
||||
float top=round?28:7;
|
||||
float top=round?20:7;
|
||||
float headerInset=inset(top,23);
|
||||
action("⌄","hide",headerInset,top,32,23,23,false);
|
||||
String title=state.expanded?"完整候选":state.tools?"编辑工具":"蟹蟹输入";
|
||||
@@ -113,9 +135,7 @@ final class KeyboardSurface extends ViewGroup {
|
||||
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",composition.isEmpty()?modeName():composition,15,BLUE);compose.setMaxLines(1);
|
||||
TextView compose=label("composition",compositionText(),15,BLUE);compose.setMaxLines(1);
|
||||
float ci=inset(compositionY,23);place(compose,ci,compositionY,410-2*ci,23);
|
||||
float candidateY=compositionY+26;
|
||||
if(isChinese()) {
|
||||
@@ -138,9 +158,17 @@ final class KeyboardSurface extends ViewGroup {
|
||||
}
|
||||
float keyHeight=captions.length==4?36:49;
|
||||
// Hide an unused suggestion strip in direct entry modes to enlarge the keys.
|
||||
if(!isChinese()){keyHeight=captions.length==4?43:66;}
|
||||
else if(state.mode.equals("py")){keyHeight=55;}
|
||||
for(int i=0;i<captions.length;i++)row(captions[i],values[i],keyY+i*(keyHeight+4),keyHeight,captions[i].length>=7?21:24);
|
||||
if(!isChinese()){keyHeight=captions.length==4?43:round?68:77;}
|
||||
else if(state.mode.equals("py")){keyHeight=round?56:61;}
|
||||
boolean qwerty=state.mode.equals("py")||state.mode.equals("en");
|
||||
float common=Float.MAX_VALUE;
|
||||
if(qwerty)for(int i=0;i<captions.length;i++)common=Math.min(common,(410-2*inset(keyY+i*(keyHeight+4),keyHeight))/captions[i].length);
|
||||
for(int i=0;i<captions.length;i++){
|
||||
float y=keyY+i*(keyHeight+4);
|
||||
if(!qwerty){row(captions[i],values[i],y,keyHeight,captions[i].length>=7?21:24);continue;}
|
||||
float left=(410-common*captions[i].length)/2;
|
||||
for(int j=0;j<captions[i].length;j++)action(captions[i][j],values[i][j],left+j*common+1,y,common-2,keyHeight,21,false);
|
||||
}
|
||||
float modeY=keyY+captions.length*(keyHeight+4)+3;
|
||||
row(new String[]{"九宫","26键","14键",state.mode.equals("en")?(state.upper?"小写":"大写"):"abc","123","符号"},new String[]{"mode:t9","mode:py","mode:14","mode:en","mode:num","mode:sym"},modeY,27,13);
|
||||
bottom(modeY+34,false);
|
||||
@@ -186,7 +214,7 @@ final class KeyboardSurface extends ViewGroup {
|
||||
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);
|
||||
if(width!=builtWidth||height!=builtHeight||!layoutKey().equals(builtKey)||state.expanded&&builtCandidates!=state.candidates)build(width,height);else if(!state.expanded&&!state.tools)updateContent();
|
||||
for(Placement item:placements)item.view.measure(MeasureSpec.makeMeasureSpec(item.bounds.width(),MeasureSpec.EXACTLY),MeasureSpec.makeMeasureSpec(item.bounds.height(),MeasureSpec.EXACTLY));
|
||||
setMeasuredDimension(width,height);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import android.os.CancellationSignal;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Full upstream dictionary, offline. Called only on the service's single worker. */
|
||||
public final class Lexicon implements AutoCloseable {
|
||||
@@ -17,6 +19,7 @@ public final class Lexicon implements AutoCloseable {
|
||||
public static final String[] GROUP9={"abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};
|
||||
private final Context context;
|
||||
private SQLiteDatabase db;
|
||||
private static final Pattern CONTEXT=Pattern.compile("[\\u3400-\\u9fff]{1,4}$");
|
||||
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";
|
||||
@@ -43,18 +46,26 @@ public final class Lexicon implements AutoCloseable {
|
||||
return lookup(input,mode,"");
|
||||
}
|
||||
public List<Candidate> predict(String context) throws Exception {
|
||||
return predict(context,null);
|
||||
}
|
||||
public List<Candidate> predict(String context,CancellationSignal signal) throws Exception {
|
||||
if(signal!=null)signal.throwIfCanceled();
|
||||
List<Candidate> result=new ArrayList<>();
|
||||
java.util.regex.Matcher match=java.util.regex.Pattern.compile("[\\u3400-\\u9fff]{1,4}$").matcher(context==null?"":context);
|
||||
java.util.regex.Matcher match=CONTEXT.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)})){
|
||||
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)},signal)){
|
||||
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 {
|
||||
return lookup(input,mode,context,null);
|
||||
}
|
||||
public List<Candidate> lookup(String input,String mode,String context,CancellationSignal signal) throws Exception {
|
||||
if(signal!=null)signal.throwIfCanceled();
|
||||
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();
|
||||
@@ -63,7 +74,7 @@ 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),w.frequency 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,signal)) {
|
||||
while(cursor.moveToNext()) {
|
||||
if(Thread.currentThread().isInterrupted())return new ArrayList<>();
|
||||
String word=cursor.getString(0);int consume=cursor.getInt(1);
|
||||
@@ -72,7 +83,7 @@ public final class Lexicon implements AutoCloseable {
|
||||
}
|
||||
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)})){
|
||||
try(Cursor cursor=db.rawQuery(sql,new String[]{input,input+'\uffff',Integer.toString(input.length()+4)},signal)){
|
||||
int added=0;
|
||||
while(cursor.moveToNext()&&added<12){
|
||||
if(Thread.currentThread().isInterrupted())return new ArrayList<>();
|
||||
@@ -81,7 +92,7 @@ public final class Lexicon implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
}
|
||||
CandidateRanking.sort(result,input.length(),predict(context));
|
||||
CandidateRanking.sort(result,input.length(),predict(context,signal));
|
||||
return result;
|
||||
}
|
||||
@Override public void close() { if(db!=null){db.close();db=null;} }
|
||||
|
||||
@@ -20,6 +20,13 @@ public class CandidatePagerTest {
|
||||
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 largeDictionaryMaterializesOnlyTheRequestedPage(){
|
||||
List<Candidate> input=new ArrayList<>();for(int i=0;i<12000;i++)input.add(new Candidate(i%13==0?"完整词组":String.valueOf((char)(0x4E00+i%1200)),3));
|
||||
List<List<CandidatePager.Row>> pages=CandidatePager.pages(input);List<Candidate> restored=new ArrayList<>();
|
||||
assertTrue(pages.size()>700);assertNotSame("Pages are created on demand",pages.get(0),pages.get(0));
|
||||
for(List<CandidatePager.Row> page:pages){int cells=0,units=0;for(CandidatePager.Row row:page){units+=row.units;for(CandidatePager.Cell cell:row.cells){restored.add(cell.candidate);cells++;}}assertTrue(cells<=16);assertTrue(units<=4);}
|
||||
assertEquals("No candidate is omitted or reordered",input,restored);
|
||||
}
|
||||
@Test public void pairedAndT9MappingsCoverExpectedWords(){
|
||||
assertEquals("c3754",Lexicon.encode("nihao","14"));assertEquals("64426",Lexicon.encode("nihao","t9"));assertEquals("",Lexicon.encode("a!","14"));
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ def main():
|
||||
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)
|
||||
expected=4 if args.background_regression else 9
|
||||
expected=7 if args.background_regression else 9
|
||||
passed=result.returncode==0 and f'OK ({expected} tests)'in output and 'FAILURES!!!'not in output
|
||||
version=re.search(r"versionName\s+'([^']+)'",(APP/'app/build.gradle').read_text(encoding='utf-8')).group(1)
|
||||
report={'model':model,'sdk':sdk,'version':version,'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}
|
||||
|
||||
在新工单中引用
屏蔽一个用户