diff --git a/samples/SampleInputMethodAccessibilityService/Android.bp b/samples/SampleInputMethodAccessibilityService/Android.bp new file mode 100644 index 000000000..311004b1e --- /dev/null +++ b/samples/SampleInputMethodAccessibilityService/Android.bp @@ -0,0 +1,31 @@ +// +// Copyright (C) 2022 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_app { + name: "SampleInputMethodAccessibilityService", + min_sdk_version: "33", + target_sdk_version: "33", + sdk_version: "current", + srcs: ["src/**/*.java"], + resource_dirs: ["res"], + static_libs: [ + "androidx.annotation_annotation", + ], +} diff --git a/samples/SampleInputMethodAccessibilityService/AndroidManifest.xml b/samples/SampleInputMethodAccessibilityService/AndroidManifest.xml new file mode 100755 index 000000000..7f85fb936 --- /dev/null +++ b/samples/SampleInputMethodAccessibilityService/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + diff --git a/samples/SampleInputMethodAccessibilityService/res/xml/accessibility.xml b/samples/SampleInputMethodAccessibilityService/res/xml/accessibility.xml new file mode 100644 index 000000000..d80e46a6b --- /dev/null +++ b/samples/SampleInputMethodAccessibilityService/res/xml/accessibility.xml @@ -0,0 +1,21 @@ + + + diff --git a/samples/SampleInputMethodAccessibilityService/src/com/example/android/sampleinputmethodaccessibilityservice/DragToMoveTouchListener.java b/samples/SampleInputMethodAccessibilityService/src/com/example/android/sampleinputmethodaccessibilityservice/DragToMoveTouchListener.java new file mode 100644 index 000000000..08e4b20ac --- /dev/null +++ b/samples/SampleInputMethodAccessibilityService/src/com/example/android/sampleinputmethodaccessibilityservice/DragToMoveTouchListener.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.sampleinputmethodaccessibilityservice; + +import android.view.MotionEvent; +import android.view.View; + +final class DragToMoveTouchListener implements View.OnTouchListener { + @FunctionalInterface + interface OnMoveCallback { + void onMove(int dx, int dy); + } + + private final OnMoveCallback mCallback; + private int mPointId = -1; + private float mLastTouchX; + private float mLastTouchY; + + DragToMoveTouchListener(OnMoveCallback callback) { + mCallback = callback; + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + final int pointId = event.getPointerId(event.getActionIndex()); + switch (event.getAction()) { + case MotionEvent.ACTION_POINTER_DOWN: + case MotionEvent.ACTION_DOWN: { + if (mPointId != -1) { + break; + } + v.setPressed(true); + mPointId = pointId; + mLastTouchX = event.getRawX(); + mLastTouchY = event.getRawY(); + break; + } + case MotionEvent.ACTION_MOVE: { + if (pointId != mPointId) { + break; + } + final float x = event.getRawX(); + final float y = event.getRawY(); + final float dx = x - mLastTouchX; + final float dy = y - mLastTouchY; + mCallback.onMove((int) dx, (int) dy); + mLastTouchX = x; + mLastTouchY = y; + break; + } + case MotionEvent.ACTION_POINTER_UP: + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: { + if (pointId != mPointId) { + break; + } + mPointId = -1; + v.setPressed(false); + break; + } + default: + break; + } + return true; + } +} diff --git a/samples/SampleInputMethodAccessibilityService/src/com/example/android/sampleinputmethodaccessibilityservice/EditorInfoUtil.java b/samples/SampleInputMethodAccessibilityService/src/com/example/android/sampleinputmethodaccessibilityservice/EditorInfoUtil.java new file mode 100644 index 000000000..74206aa60 --- /dev/null +++ b/samples/SampleInputMethodAccessibilityService/src/com/example/android/sampleinputmethodaccessibilityservice/EditorInfoUtil.java @@ -0,0 +1,279 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.sampleinputmethodaccessibilityservice; + +import android.view.inputmethod.EditorInfo; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +final class EditorInfoUtil { + /** + * Not intended to be instantiated. + */ + private EditorInfoUtil() { + } + + static String dump(@Nullable EditorInfo editorInfo) { + if (editorInfo == null) { + return "null"; + } + final StringBuilder sb = new StringBuilder(); + dump(sb, editorInfo); + return sb.toString(); + } + + static void dump(@NonNull StringBuilder sb, @NonNull EditorInfo editorInfo) { + sb.append("packageName=").append(editorInfo.packageName).append("\n") + .append("inputType="); + dumpInputType(sb, editorInfo.inputType); + sb.append("\n"); + sb.append("imeOptions="); + dumpImeOptions(sb, editorInfo.imeOptions); + sb.append("\n"); + sb.append("initialSelection=(").append(editorInfo.initialSelStart) + .append(",").append(editorInfo.initialSelEnd).append(")"); + sb.append("\n"); + sb.append("initialCapsMode="); + dumpCapsMode(sb, editorInfo.initialCapsMode); + sb.append("\n"); + } + + static void dumpInputType(@NonNull StringBuilder sb, int inputType) { + final int inputClass = inputType & EditorInfo.TYPE_MASK_CLASS; + final int inputVariation = inputType & EditorInfo.TYPE_MASK_VARIATION; + final int inputFlags = inputType & EditorInfo.TYPE_MASK_FLAGS; + switch (inputClass) { + case EditorInfo.TYPE_NULL: + sb.append("Null"); + break; + case EditorInfo.TYPE_CLASS_TEXT: { + sb.append("Text"); + switch (inputVariation) { + case EditorInfo.TYPE_TEXT_VARIATION_NORMAL: + break; + case EditorInfo.TYPE_TEXT_VARIATION_URI: + sb.append(":URI"); + break; + case EditorInfo.TYPE_TEXT_VARIATION_EMAIL_ADDRESS: + sb.append(":EMAIL_ADDRESS"); + break; + case EditorInfo.TYPE_TEXT_VARIATION_EMAIL_SUBJECT: + sb.append(":EMAIL_SUBJECT"); + break; + case EditorInfo.TYPE_TEXT_VARIATION_SHORT_MESSAGE: + sb.append(":SHORT_MESSAGE"); + break; + case EditorInfo.TYPE_TEXT_VARIATION_LONG_MESSAGE: + sb.append(":LONG_MESSAGE"); + break; + case EditorInfo.TYPE_TEXT_VARIATION_PERSON_NAME: + sb.append(":PERSON_NAME"); + break; + case EditorInfo.TYPE_TEXT_VARIATION_POSTAL_ADDRESS: + sb.append(":POSTAL_ADDRESS"); + break; + case EditorInfo.TYPE_TEXT_VARIATION_PASSWORD: + sb.append(":PASSWORD"); + break; + case EditorInfo.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD: + sb.append(":VISIBLE_PASSWORD"); + break; + case EditorInfo.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT: + sb.append(":WEB_EDIT_TEXT"); + break; + case EditorInfo.TYPE_TEXT_VARIATION_FILTER: + sb.append(":FILTER"); + break; + case EditorInfo.TYPE_TEXT_VARIATION_PHONETIC: + sb.append(":PHONETIC"); + break; + case EditorInfo.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS: + sb.append(":WEB_EMAIL_ADDRESS"); + break; + case EditorInfo.TYPE_TEXT_VARIATION_WEB_PASSWORD: + sb.append(":WEB_PASSWORD"); + break; + default: + sb.append(":UNKNOWN=").append(inputVariation); + break; + } + if ((inputFlags & EditorInfo.TYPE_TEXT_FLAG_CAP_CHARACTERS) != 0) { + sb.append("|CAP_CHARACTERS"); + } + if ((inputFlags & EditorInfo.TYPE_TEXT_FLAG_CAP_WORDS) != 0) { + sb.append("|CAP_WORDS"); + } + if ((inputFlags & EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES) != 0) { + sb.append("|CAP_SENTENCES"); + } + if ((inputFlags & EditorInfo.TYPE_TEXT_FLAG_AUTO_CORRECT) != 0) { + sb.append("|AUTO_CORRECT"); + } + if ((inputFlags & EditorInfo.TYPE_TEXT_FLAG_AUTO_COMPLETE) != 0) { + sb.append("|AUTO_COMPLETE"); + } + if ((inputFlags & EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE) != 0) { + sb.append("|MULTI_LINE"); + } + if ((inputFlags & EditorInfo.TYPE_TEXT_FLAG_NO_SUGGESTIONS) != 0) { + sb.append("|NO_SUGGESTIONS"); + } + if ((inputFlags & EditorInfo.TYPE_TEXT_FLAG_ENABLE_TEXT_CONVERSION_SUGGESTIONS) + != 0) { + sb.append("|ENABLE_TEXT_CONVERSION_SUGGESTIONS"); + } + break; + } + case EditorInfo.TYPE_CLASS_NUMBER: { + sb.append("Number"); + switch (inputVariation) { + case EditorInfo.TYPE_NUMBER_VARIATION_NORMAL: + sb.append(":NORMAL"); + break; + case EditorInfo.TYPE_NUMBER_VARIATION_PASSWORD: + sb.append(":PASSWORD"); + break; + default: + sb.append(":UNKNOWN=").append(inputVariation); + break; + } + if ((inputFlags & EditorInfo.TYPE_NUMBER_FLAG_SIGNED) != 0) { + sb.append("|SIGNED"); + } + if ((inputFlags & EditorInfo.TYPE_NUMBER_FLAG_DECIMAL) != 0) { + sb.append("|DECIMAL"); + } + break; + } + case EditorInfo.TYPE_CLASS_PHONE: + sb.append("Phone"); + break; + case EditorInfo.TYPE_CLASS_DATETIME: { + sb.append("DateTime"); + switch (inputVariation) { + case EditorInfo.TYPE_DATETIME_VARIATION_NORMAL: + sb.append(":NORMAL"); + break; + case EditorInfo.TYPE_DATETIME_VARIATION_DATE: + sb.append(":DATE"); + break; + case EditorInfo.TYPE_DATETIME_VARIATION_TIME: + sb.append(":TIME"); + break; + default: + sb.append(":UNKNOWN=").append(inputVariation); + break; + } + break; + } + default: + sb.append("UnknownClass=").append(inputClass); + if (inputVariation != 0) { + sb.append(":variation=").append(inputVariation); + } + if (inputFlags != 0) { + sb.append("|flags=0x").append(Integer.toHexString(inputFlags)); + } + break; + } + } + + static void dumpImeOptions(@NonNull StringBuilder sb, int imeOptions) { + final int action = imeOptions & EditorInfo.IME_MASK_ACTION; + final int flags = imeOptions & ~EditorInfo.IME_MASK_ACTION; + sb.append("Action:"); + switch (action) { + case EditorInfo.IME_ACTION_UNSPECIFIED: + sb.append("UNSPECIFIED"); + break; + case EditorInfo.IME_ACTION_NONE: + sb.append("NONE"); + break; + case EditorInfo.IME_ACTION_GO: + sb.append("GO"); + break; + case EditorInfo.IME_ACTION_SEARCH: + sb.append("SEARCH"); + break; + case EditorInfo.IME_ACTION_SEND: + sb.append("SEND"); + break; + case EditorInfo.IME_ACTION_NEXT: + sb.append("NEXT"); + break; + case EditorInfo.IME_ACTION_DONE: + sb.append("DONE"); + break; + case EditorInfo.IME_ACTION_PREVIOUS: + sb.append("PREVIOUS"); + break; + default: + sb.append("UNKNOWN=").append(action); + break; + } + if ((flags & EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING) != 0) { + sb.append("|NO_PERSONALIZED_LEARNING"); + } + if ((flags & EditorInfo.IME_FLAG_NO_FULLSCREEN) != 0) { + sb.append("|NO_FULLSCREEN"); + } + if ((flags & EditorInfo.IME_FLAG_NAVIGATE_PREVIOUS) != 0) { + sb.append("|NAVIGATE_PREVIOUS"); + } + if ((flags & EditorInfo.IME_FLAG_NAVIGATE_NEXT) != 0) { + sb.append("|NAVIGATE_NEXT"); + } + if ((flags & EditorInfo.IME_FLAG_NO_EXTRACT_UI) != 0) { + sb.append("|NO_EXTRACT_UI"); + } + if ((flags & EditorInfo.IME_FLAG_NO_ACCESSORY_ACTION) != 0) { + sb.append("|NO_ACCESSORY_ACTION"); + } + if ((flags & EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0) { + sb.append("|NO_ENTER_ACTION"); + } + if ((flags & EditorInfo.IME_FLAG_FORCE_ASCII) != 0) { + sb.append("|FORCE_ASCII"); + } + } + + static void dumpCapsMode(@NonNull StringBuilder sb, int capsMode) { + if (capsMode == 0) { + sb.append("none"); + return; + } + boolean addSeparator = false; + if ((capsMode & EditorInfo.TYPE_TEXT_FLAG_CAP_CHARACTERS) != 0) { + sb.append("CHARACTERS"); + addSeparator = true; + } + if ((capsMode & EditorInfo.TYPE_TEXT_FLAG_CAP_WORDS) != 0) { + if (addSeparator) { + sb.append('|'); + } + sb.append("WORDS"); + addSeparator = true; + } + if ((capsMode & EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES) != 0) { + if (addSeparator) { + sb.append('|'); + } + sb.append("SENTENCES"); + } + } +} diff --git a/samples/SampleInputMethodAccessibilityService/src/com/example/android/sampleinputmethodaccessibilityservice/EventMonitor.java b/samples/SampleInputMethodAccessibilityService/src/com/example/android/sampleinputmethodaccessibilityservice/EventMonitor.java new file mode 100644 index 000000000..ca673b32c --- /dev/null +++ b/samples/SampleInputMethodAccessibilityService/src/com/example/android/sampleinputmethodaccessibilityservice/EventMonitor.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.sampleinputmethodaccessibilityservice; + +import android.os.Parcel; +import android.view.inputmethod.EditorInfo; + +import androidx.annotation.Nullable; + +final class EventMonitor { + @FunctionalInterface + interface DebugMessageCallback { + void onMessageChanged(String message); + } + + private enum State { + BeforeFirstStartInput, + InputStarted, + InputRestarted, + InputFinished, + } + + private State mState = State.BeforeFirstStartInput; + private int mStartInputCount = 0; + private int mUpdateSelectionCount = 0; + private int mFinishInputCount = 0; + private int mSelStart = -1; + private int mSelEnd = -1; + private int mCompositionStart = -1; + private int mCompositionEnd = -1; + @Nullable + private EditorInfo mEditorInfo; + + @Nullable + private final DebugMessageCallback mDebugMessageCallback; + + void onStartInput(EditorInfo attribute, boolean restarting) { + ++mStartInputCount; + mState = restarting ? State.InputRestarted : State.InputStarted; + mSelStart = attribute.initialSelStart; + mSelEnd = attribute.initialSelEnd; + mCompositionStart = -1; + mCompositionEnd = -1; + mEditorInfo = cloneEditorInfo(attribute); + updateMessage(); + } + + void onFinishInput() { + ++mFinishInputCount; + mState = State.InputFinished; + mEditorInfo = null; + updateMessage(); + } + + void onUpdateSelection(int oldSelStart, int oldSelEnd, int newSelStart, + int newSelEnd, int candidatesStart, int candidatesEnd) { + ++mUpdateSelectionCount; + mSelStart = newSelStart; + mSelEnd = newSelEnd; + mCompositionStart = candidatesStart; + mCompositionEnd = candidatesEnd; + updateMessage(); + } + + EventMonitor(@Nullable DebugMessageCallback callback) { + mDebugMessageCallback = callback; + } + + private void updateMessage() { + if (mDebugMessageCallback == null) { + return; + } + + final StringBuilder sb = new StringBuilder(); + sb.append("state=").append(mState).append("\n") + .append("startInputCount=").append(mStartInputCount).append("\n") + .append("finishInputCount=").append(mFinishInputCount).append("\n") + .append("updateSelectionCount=").append(mUpdateSelectionCount).append("\n"); + if (mSelStart == -1 && mSelEnd == -1) { + sb.append("selection=none\n"); + } else { + sb.append("selection=(").append(mSelStart).append(",").append(mSelEnd).append(")\n"); + } + if (mCompositionStart == -1 && mCompositionEnd == -1) { + sb.append("composition=none"); + } else { + sb.append("composition=(") + .append(mCompositionStart).append(",").append(mCompositionEnd).append(")"); + } + if (mEditorInfo != null) { + sb.append("\n"); + sb.append("packageName=").append(mEditorInfo.packageName).append("\n"); + sb.append("inputType="); + EditorInfoUtil.dumpInputType(sb, mEditorInfo.inputType); + sb.append("\n"); + sb.append("imeOptions="); + EditorInfoUtil.dumpImeOptions(sb, mEditorInfo.imeOptions); + } + + mDebugMessageCallback.onMessageChanged(sb.toString()); + } + + private static EditorInfo cloneEditorInfo(EditorInfo original) { + Parcel parcel = null; + try { + parcel = Parcel.obtain(); + original.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + return EditorInfo.CREATOR.createFromParcel(parcel); + } finally { + if (parcel != null) { + parcel.recycle(); + } + } + } +} diff --git a/samples/SampleInputMethodAccessibilityService/src/com/example/android/sampleinputmethodaccessibilityservice/OverlayWindowBuilder.java b/samples/SampleInputMethodAccessibilityService/src/com/example/android/sampleinputmethodaccessibilityservice/OverlayWindowBuilder.java new file mode 100644 index 000000000..87542f838 --- /dev/null +++ b/samples/SampleInputMethodAccessibilityService/src/com/example/android/sampleinputmethodaccessibilityservice/OverlayWindowBuilder.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.sampleinputmethodaccessibilityservice; + +import android.content.Context; +import android.graphics.PixelFormat; +import android.view.Gravity; +import android.view.View; +import android.view.WindowManager; +import android.view.accessibility.AccessibilityEvent; +import android.widget.FrameLayout; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +final class OverlayWindowBuilder { + @NonNull + private final View mContentView; + private int mWidth = WindowManager.LayoutParams.WRAP_CONTENT; + private int mHeight = WindowManager.LayoutParams.WRAP_CONTENT; + private int mGravity = Gravity.NO_GRAVITY; + private int mRelX = 0; + private int mRelY = 0; + private Integer mBackgroundColor = null; + private boolean mShown = false; + + private OverlayWindowBuilder(@NonNull View contentView) { + mContentView = contentView; + } + + static OverlayWindowBuilder from(@NonNull View contentView) { + return new OverlayWindowBuilder(contentView); + } + + OverlayWindowBuilder setSize(int width, int height) { + mWidth = width; + mHeight = height; + return this; + } + + OverlayWindowBuilder setGravity(int gravity) { + mGravity = gravity; + return this; + } + + OverlayWindowBuilder setRelativePosition(int relX, int relY) { + mRelX = relX; + mRelY = relY; + return this; + } + + OverlayWindowBuilder setBackgroundColor(@ColorInt int color) { + mBackgroundColor = color; + return this; + } + + void show() { + if (mShown) { + throw new UnsupportedOperationException("show() can be called only once."); + } + + final Context context = mContentView.getContext(); + final WindowManager windowManager = context.getSystemService(WindowManager.class); + + final FrameLayout contentFrame = new FrameLayout(context) { + @Override + public boolean requestSendAccessibilityEvent(View view, AccessibilityEvent event) { + return false; + } + + @Override + public void sendAccessibilityEventUnchecked(AccessibilityEvent event) { + } + }; + if (mBackgroundColor != null) { + contentFrame.setBackgroundColor(mBackgroundColor); + } + contentFrame.setOnTouchListener(new DragToMoveTouchListener((dx, dy) -> { + final WindowManager.LayoutParams lp = + (WindowManager.LayoutParams) contentFrame.getLayoutParams(); + lp.x += dx; + lp.y += dy; + windowManager.updateViewLayout(contentFrame, lp); + })); + contentFrame.addView(mContentView); + final WindowManager.LayoutParams params = new WindowManager.LayoutParams( + mWidth, mHeight, + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN + | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSLUCENT); + params.gravity = mGravity; + params.x = mRelX; + params.y = mRelY; + windowManager.addView(contentFrame, params); + mShown = true; + } +} diff --git a/samples/SampleInputMethodAccessibilityService/src/com/example/android/sampleinputmethodaccessibilityservice/SampleInputMethodAccessibilityService.java b/samples/SampleInputMethodAccessibilityService/src/com/example/android/sampleinputmethodaccessibilityservice/SampleInputMethodAccessibilityService.java new file mode 100644 index 000000000..74c9e455e --- /dev/null +++ b/samples/SampleInputMethodAccessibilityService/src/com/example/android/sampleinputmethodaccessibilityservice/SampleInputMethodAccessibilityService.java @@ -0,0 +1,267 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.android.sampleinputmethodaccessibilityservice; + +import android.accessibilityservice.AccessibilityService; +import android.accessibilityservice.InputMethod; +import android.os.SystemClock; +import android.util.Log; +import android.util.Pair; +import android.view.Gravity; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.WindowManager; +import android.view.WindowMetrics; +import android.view.accessibility.AccessibilityEvent; +import android.view.inputmethod.EditorInfo; +import android.widget.Button; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.List; +import java.util.function.BiConsumer; + +/** + * A sample {@link AccessibilityService} to demo how to use IME APIs. + */ +public final class SampleInputMethodAccessibilityService extends AccessibilityService { + private static final String TAG = "SampleImeA11yService"; + + private EventMonitor mEventMonitor; + + private final class InputMethodImpl extends InputMethod { + InputMethodImpl(AccessibilityService service) { + super(service); + } + + @Override + public void onStartInput(EditorInfo attribute, boolean restarting) { + Log.d(TAG, String.format("onStartInput(%s,%b)", attribute, restarting)); + mEventMonitor.onStartInput(attribute, restarting); + } + + @Override + public void onFinishInput() { + Log.d(TAG, "onFinishInput()"); + mEventMonitor.onFinishInput(); + } + + @Override + public void onUpdateSelection(int oldSelStart, int oldSelEnd, int newSelStart, + int newSelEnd, int candidatesStart, int candidatesEnd) { + Log.d(TAG, String.format("onUpdateSelection(%d,%d,%d,%d,%d,%d)", oldSelStart, oldSelEnd, + newSelStart, newSelEnd, candidatesStart, candidatesEnd)); + mEventMonitor.onUpdateSelection(oldSelStart, oldSelEnd, + newSelStart, newSelEnd, candidatesStart, candidatesEnd); + } + } + + private static Pair item(@NonNull CharSequence label, @Nullable T value) { + return Pair.create(label, value); + } + + private void addButtons(@NonNull LinearLayout parentView, @NonNull String headerText, + @NonNull List> items, + @NonNull BiConsumer action) { + final LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.VERTICAL); + { + final TextView headerTextView = new TextView(this, null, + android.R.attr.listSeparatorTextViewStyle); + headerTextView.setAllCaps(false); + headerTextView.setText(headerText); + layout.addView(headerTextView); + } + { + final LinearLayout itemLayout = new LinearLayout(this); + itemLayout.setOrientation(LinearLayout.HORIZONTAL); + for (Pair item : items) { + final Button button = new Button(this, null, android.R.attr.buttonStyleSmall); + button.setAllCaps(false); + button.setText(item.first); + button.setOnClickListener(view -> { + final InputMethod ime = getInputMethod(); + if (ime == null) { + return; + } + final InputMethod.AccessibilityInputConnection ic = + ime.getCurrentInputConnection(); + if (ic == null) { + return; + } + action.accept(item.second, ic); + }); + itemLayout.addView(button); + } + final HorizontalScrollView scrollView = new HorizontalScrollView(this); + scrollView.addView(itemLayout); + layout.addView(scrollView); + } + parentView.addView(layout); + } + + @Override + protected void onServiceConnected() { + super.onServiceConnected(); + + final WindowManager windowManager = getSystemService(WindowManager.class); + final WindowMetrics metrics = windowManager.getCurrentWindowMetrics(); + + // Create a monitor window. + { + final TextView textView = new TextView(this); + mEventMonitor = new EventMonitor(textView::setText); + + final LinearLayout monitorWindowContent = new LinearLayout(this); + monitorWindowContent.setOrientation(LinearLayout.VERTICAL); + monitorWindowContent.setPadding(10, 10, 10, 10); + + monitorWindowContent.addView(textView); + + OverlayWindowBuilder.from(monitorWindowContent) + .setSize((metrics.getBounds().width() * 3) / 4, + WindowManager.LayoutParams.WRAP_CONTENT) + .setGravity(Gravity.TOP | Gravity.CENTER_HORIZONTAL) + .setBackgroundColor(0xeed2e3fc) + .show(); + } + + final LinearLayout contentView = new LinearLayout(this); + contentView.setOrientation(LinearLayout.VERTICAL); + { + final TextView textView = new TextView(this, null, android.R.attr.windowTitleStyle); + textView.setGravity(Gravity.CENTER); + textView.setText("A11Y IME"); + contentView.addView(textView); + } + { + final LinearLayout buttonLayout = new LinearLayout(this); + buttonLayout.setBackgroundColor(0xfffeefc3); + buttonLayout.setPadding(10, 10, 10, 10); + buttonLayout.setOrientation(LinearLayout.VERTICAL); + + addButtons(buttonLayout, + "commitText", List.of( + item("A", "A"), + item("Hello World", "Hello World"), + item("\uD83D\uDE36\u200D\uD83C\uDF2B\uFE0F", + "\uD83D\uDE36\u200D\uD83C\uDF2B\uFE0F")), + (value, ic) -> ic.commitText(value, 1, null)); + + addButtons(buttonLayout, + "sendKeyEvent", List.of( + item("A", KeyEvent.KEYCODE_A), + item("DEL", KeyEvent.KEYCODE_DEL), + item("DPAD_LEFT", KeyEvent.KEYCODE_DPAD_LEFT), + item("DPAD_RIGHT", KeyEvent.KEYCODE_DPAD_RIGHT), + item("COPY", KeyEvent.KEYCODE_COPY), + item("CUT", KeyEvent.KEYCODE_CUT), + item("PASTE", KeyEvent.KEYCODE_PASTE)), + (keyCode, ic) -> { + final long eventTime = SystemClock.uptimeMillis(); + ic.sendKeyEvent(new KeyEvent(eventTime, eventTime, + KeyEvent.ACTION_DOWN, keyCode, 0, 0, + KeyCharacterMap.VIRTUAL_KEYBOARD, 0, + KeyEvent.FLAG_SOFT_KEYBOARD | KeyEvent.FLAG_KEEP_TOUCH_MODE)); + ic.sendKeyEvent(new KeyEvent(eventTime, SystemClock.uptimeMillis(), + KeyEvent.ACTION_UP, keyCode, 0, 0, + KeyCharacterMap.VIRTUAL_KEYBOARD, 0, + KeyEvent.FLAG_SOFT_KEYBOARD | KeyEvent.FLAG_KEEP_TOUCH_MODE)); + }); + + addButtons(buttonLayout, + "performEditorAction", List.of( + item("UNSPECIFIED", EditorInfo.IME_ACTION_UNSPECIFIED), + item("NONE", EditorInfo.IME_ACTION_NONE), + item("GO", EditorInfo.IME_ACTION_GO), + item("SEARCH", EditorInfo.IME_ACTION_SEARCH), + item("SEND", EditorInfo.IME_ACTION_SEND), + item("NEXT", EditorInfo.IME_ACTION_NEXT), + item("DONE", EditorInfo.IME_ACTION_DONE), + item("PREVIOUS", EditorInfo.IME_ACTION_PREVIOUS)), + (action, ic) -> ic.performEditorAction(action)); + + addButtons(buttonLayout, + "performContextMenuAction", List.of( + item("selectAll", android.R.id.selectAll), + item("startSelectingText", android.R.id.startSelectingText), + item("stopSelectingText", android.R.id.stopSelectingText), + item("cut", android.R.id.cut), + item("copy", android.R.id.copy), + item("paste", android.R.id.paste), + item("copyUrl", android.R.id.copyUrl), + item("switchInputMethod", android.R.id.switchInputMethod)), + (action, ic) -> ic.performContextMenuAction(action)); + + addButtons(buttonLayout, + "setSelection", List.of( + item("(0,0)", Pair.create(0, 0)), + item("(0,1)", Pair.create(0, 1)), + item("(1,1)", Pair.create(1, 1)), + item("(0,999)", Pair.create(0, 999))), + (pair, ic) -> ic.setSelection(pair.first, pair.second)); + + addButtons(buttonLayout, + "deleteSurroundingText", List.of( + item("(0,0)", Pair.create(0, 0)), + item("(0,1)", Pair.create(0, 1)), + item("(1,0)", Pair.create(1, 0)), + item("(1,1)", Pair.create(1, 1)), + item("(999,0)", Pair.create(999, 0)), + item("(0,999)", Pair.create(0, 999))), + (pair, ic) -> ic.deleteSurroundingText(pair.first, pair.second)); + + final ScrollView scrollView = new ScrollView(this); + scrollView.addView(buttonLayout); + contentView.addView(scrollView); + + // Set margin + { + final LinearLayout.LayoutParams lp = + ((LinearLayout.LayoutParams) scrollView.getLayoutParams()); + lp.leftMargin = lp.rightMargin = lp.bottomMargin = 20; + scrollView.setLayoutParams(lp); + } + } + + OverlayWindowBuilder.from(contentView) + .setSize((metrics.getBounds().width() * 3) / 4, + metrics.getBounds().height() / 5) + .setGravity(Gravity.TOP | Gravity.CENTER_HORIZONTAL) + .setRelativePosition(300, 300) + .setBackgroundColor(0xfffcc934) + .show(); + } + + @Override + public void onAccessibilityEvent(AccessibilityEvent event) { + } + + @Override + public void onInterrupt() { + } + + @Override + public InputMethod onCreateInputMethod() { + Log.d(TAG, "onCreateInputMethod"); + return new InputMethodImpl(this); + } +}