feat: 发布4×4候选与Android手表应用内键盘组件
这个提交包含在:
1 父节点
82aeeba816
当前提交
87aa966a51
86 个文件已更改
+2697
-2975
没有与搜索条件相匹配的文件
@@ -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 *; }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
在新工单中引用
屏蔽一个用户